Skip to content

Commit 6eb9449

Browse files
committed
Merge fixes
2 parents 311c400 + e871762 commit 6eb9449

File tree

77 files changed

+1838
-855
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

77 files changed

+1838
-855
lines changed

mathesar_ui/src/App.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,8 @@
130130
131131
color: var(--color-fg-base);
132132
133-
--modal-z-index: 1;
134-
--dropdown-z-index: 1;
133+
--modal-z-index: 2;
134+
--dropdown-z-index: 2;
135135
--cell-errors-z-index: 1;
136136
--new-item-highlighter-z-index: 1;
137137
--toast-z-index: 3;

mathesar_ui/src/AppContext.svelte

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@
2020
rowSeekerContext,
2121
} from '@mathesar/systems/row-seeker/AttachableRowSeekerController';
2222
import type { CommonData } from '@mathesar/utils/preloadData';
23-
import { Confirmation, ToastPresenter } from '@mathesar-component-library';
23+
import {
24+
Confirmation,
25+
ContextMenu,
26+
ContextMenuController,
27+
ToastPresenter,
28+
} from '@mathesar-component-library';
2429
2530
import ControlledFileDetailDropdown from './components/file-attachments/file-detail-dropdown/ControlledFileDetailDropdown.svelte';
2631
import {
@@ -35,6 +40,7 @@
3540
LightboxController,
3641
lightboxContext,
3742
} from './components/file-attachments/lightbox/LightboxController';
43+
import { contextMenuContext } from './contexts/contextMenuContext';
3844
3945
export let commonData: CommonData;
4046
@@ -77,6 +83,9 @@
7783
const modalFileAttachmentUploader = new ModalFileAttachmentUploadController();
7884
modalFileAttachmentUploadContext.set(modalFileAttachmentUploader);
7985
86+
const contextMenu = new ContextMenuController();
87+
contextMenuContext.set(contextMenu);
88+
8089
const clipboardHandlerStore = setNewClipboardHandlerStoreInContext();
8190
$: clipboardHandler = $clipboardHandlerStore;
8291
@@ -123,5 +132,6 @@
123132
<ControlledLightbox controller={lightboxController} />
124133
<ControlledFileDetailDropdown controller={fileDetailDropdownController} />
125134
<ModalFileAttachmentUploader controller={modalFileAttachmentUploader} />
135+
<ContextMenu controller={contextMenu} />
126136

127137
<slot />

mathesar_ui/src/component-library/common/actions/clickOffBounds.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
import { some } from 'iter-tools';
12
import type { ActionReturn } from 'svelte/action';
23
import { type Readable, get } from 'svelte/store';
34

45
type CallbackFn = (e: Event) => void;
56
interface Options {
67
callback: CallbackFn;
7-
references?: Readable<(HTMLElement | undefined)[]>;
8+
references?: Readable<Iterable<HTMLElement | undefined>>;
89
}
910

1011
export default function clickOffBounds(
@@ -13,17 +14,21 @@ export default function clickOffBounds(
1314
): ActionReturn {
1415
let { callback, references } = options;
1516

16-
function outOfBoundsListener(event: Event) {
17-
const isWithinReferenceElement =
18-
references &&
19-
get(references)?.some(
20-
(reference) => reference?.contains?.(event.target as Node) ?? false,
21-
);
22-
if (!isWithinReferenceElement && !node.contains(event.target as Node)) {
23-
callback(event);
17+
function* getReferenceElements(): Generator<HTMLElement> {
18+
if (!references) return;
19+
for (const element of get(references)) {
20+
if (!element) continue;
21+
yield element;
2422
}
2523
}
2624

25+
function outOfBoundsListener(event: Event) {
26+
const target = event.target as Node;
27+
if (some((ref) => ref.contains(target), getReferenceElements())) return;
28+
if (node.contains(target)) return;
29+
callback(event);
30+
}
31+
2732
/**
2833
* When the browser supports pointer events, we use the pointerdown event
2934
* which is fired for all mouse buttons and touches. However, older Safari
Lines changed: 68 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,46 @@
11
import { tick } from 'svelte';
22
import type { ActionReturn } from 'svelte/action';
33

4-
import { focusElement, hasProperty } from '../utils';
5-
6-
function getTabIndex(element: unknown): number | undefined {
7-
return hasProperty(element, 'tabIndex') &&
8-
typeof element.tabIndex === 'number'
9-
? element.tabIndex
10-
: undefined;
11-
}
12-
13-
/**
14-
* Yields focusable elements within a container. The elements are yielded in DOM
15-
* order (depth first, pre-order).
16-
*/
17-
function* getFocusableElementsInDomOrder(
18-
container: Element,
19-
): Generator<{ element: Element; tabIndex: number }> {
20-
// Cast a wide net with selector for potentially focusable elements
21-
const selectors = [
22-
'input',
23-
'button',
24-
'select',
25-
'textarea',
26-
'a[href]',
27-
'area[href]',
28-
'iframe',
29-
'object',
30-
'embed',
31-
'[tabindex]',
32-
'[contenteditable="true"]',
33-
'[contenteditable=""]',
34-
'audio[controls]',
35-
'video[controls]',
36-
'details',
37-
'summary',
38-
];
39-
const potentiallyFocusable = container.querySelectorAll(selectors.join(', '));
40-
41-
for (const element of potentiallyFocusable) {
42-
// Narrow the net by checking additional properties of each element...
43-
44-
const tabIndex = getTabIndex(element);
45-
46-
// Filter out elements which don't have a tabIndex property at all
47-
if (tabIndex === undefined) continue;
48-
49-
// Filter out elements with negative tabIndex, e.g. div
50-
if (tabIndex < 0) continue;
51-
52-
// Filter out disabled elements
53-
if (hasProperty(element, 'disabled') && element.disabled) continue;
54-
55-
// Filter out elements that are not visible
56-
const { display, visibility } = getComputedStyle(element);
57-
if (display === 'none' || visibility === 'hidden') continue;
58-
59-
// Filter out hidden input elements
60-
if (hasProperty(element, 'type') && element.type === 'hidden') continue;
61-
62-
// Yield most elements with a valid tabIndex
63-
yield { element, tabIndex };
64-
}
4+
import { focusElement, getFocusableDescendants, hasMethod } from '../utils';
5+
6+
interface FocusTrapOptions {
7+
/**
8+
* Automatically focus the first focusable element when opening. True by
9+
* default.
10+
*/
11+
autoFocus?: boolean;
12+
/**
13+
* Automatically re-focus the last-focused element when closing. True by
14+
* default.
15+
*/
16+
autoRestore?: boolean;
6517
}
6618

67-
/**
68-
* Returns the focusable elements within a container. The elements are returned
69-
* sorted in tab order (i.e. the order of their tabIndex values primarily, and
70-
* then in DOM when their tabIndex values are equal).
71-
*/
72-
function getFocusableElements(container: Element): Element[] {
73-
const elements = Array.from(getFocusableElementsInDomOrder(container));
74-
75-
// Sort elements by tabIndex only, assuming stable sort keeps DOM order
76-
elements.sort((a, b) => a.tabIndex - b.tabIndex);
19+
export default function focusTrap(
20+
container: HTMLElement,
21+
options: FocusTrapOptions = {},
22+
): ActionReturn {
23+
let previouslyFocusedElement: Element | null;
7724

78-
return elements.map(({ element }) => element);
79-
}
25+
const fullOptions = {
26+
autoFocus: true,
27+
autoRestore: true,
28+
...options,
29+
};
8030

81-
export default function focusTrap(container: HTMLElement): ActionReturn {
8231
function handleKeyDown(event: KeyboardEvent) {
8332
if (event.key !== 'Tab') return;
8433
if (event.altKey || event.ctrlKey || event.metaKey) return;
8534

35+
event.preventDefault();
36+
8637
// We are re-computing the list of focusable elements any time Tab or
8738
// Shift+Tab is pressed. This is a somewhat expensive operation. But it's
8839
// necessary because the list of focusable elements can change as state
8940
// changes (e.g. elements being added/removed from the DOM or state like
9041
// "disabled" being toggled).
91-
const elements = getFocusableElements(container);
42+
const elements = getFocusableDescendants(container);
43+
if (!elements.length) return;
9244

9345
function wrap(index: number): number {
9446
return ((index % elements.length) + elements.length) % elements.length;
@@ -100,39 +52,56 @@ export default function focusTrap(container: HTMLElement): ActionReturn {
10052
const targetElement = elements.at(targetIndex);
10153
if (!targetElement) return;
10254

103-
event.preventDefault();
10455
focusElement(targetElement);
10556
}
10657

107-
async function autoFocusFirstElement() {
108-
await tick(); // Wait, in case Svelte has more to render (e.g. children)
109-
const firstElement = getFocusableElements(container).at(0);
110-
if (firstElement) {
111-
/*
112-
* When the element is immediately focused, keydown events seem to get triggered
113-
* on the element. This results in cases like lightboxes closing immediately after
114-
* being opened when opened via an 'enter' keydown event.
115-
*
116-
* My hunch is that this bug has to do something with the way transitions/animations
117-
* are executed by svelte/transition.
118-
*
119-
* Moving the focus task to a macrotask such as setTimeout seems to prevent this
120-
* bug from occurring.
121-
*
122-
* The exact root cause / race condition is not entirely figured out.
123-
*/
124-
setTimeout(() => {
125-
focusElement(firstElement);
126-
}, 10);
58+
async function initializeFocus() {
59+
// Wait, in case Svelte has more to render (e.g. children) or in case custom
60+
// imperative focus logic has been applied (e.g. focusing the first cell
61+
// when clicking on a column header)
62+
await tick();
63+
64+
previouslyFocusedElement = document.activeElement;
65+
66+
if (fullOptions.autoFocus) {
67+
const firstElement = getFocusableDescendants(container).at(0);
68+
if (firstElement) {
69+
/*
70+
* When the element is immediately focused, keydown events seem to get
71+
* triggered on the element. This results in cases like the lightbox
72+
* closing immediately after being opened when opened via an 'enter'
73+
* keydown event.
74+
*
75+
* My hunch is that this bug has to do something with the way
76+
* transitions/animations are executed by svelte/transition.
77+
*
78+
* Moving the focus task to a macrotask such as setTimeout seems to
79+
* prevent this bug from occurring.
80+
*
81+
* The exact root cause / race condition is not entirely figured out.
82+
*/
83+
setTimeout(() => {
84+
focusElement(firstElement);
85+
}, 10);
86+
}
87+
} else if (
88+
previouslyFocusedElement &&
89+
hasMethod(previouslyFocusedElement, 'blur')
90+
) {
91+
previouslyFocusedElement.blur();
12792
}
12893
}
12994

130-
container.addEventListener('keydown', handleKeyDown);
131-
void autoFocusFirstElement();
95+
void initializeFocus();
96+
97+
window.addEventListener('keydown', handleKeyDown);
13298

13399
return {
134100
destroy() {
135-
container.removeEventListener('keydown', handleKeyDown);
101+
window.removeEventListener('keydown', handleKeyDown);
102+
if (fullOptions.autoRestore) {
103+
focusElement(previouslyFocusedElement);
104+
}
136105
},
137106
};
138107
}

mathesar_ui/src/component-library/common/actions/popper.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,19 @@ import type {
88
} from '@popperjs/core/lib/types';
99
import type { ActionReturn } from 'svelte/action';
1010

11+
interface CustomModifierOptions {
12+
/**
13+
* By default, we ensure that the popper content width is no smaller than the
14+
* width of its trigger element — unless the trigger element is wider than
15+
* 250px, in which case it ensures that the popper content width is no smaller
16+
* than 250px.
17+
*
18+
* This option controls that threshold. Set it to 0 if you want to disable
19+
* this min-width behavior, allowing the popper content to be quite narrow.
20+
*/
21+
matchTriggerWidthPxUpTo?: number;
22+
}
23+
1124
interface Parameters {
1225
reference?: VirtualElement;
1326
options?: Partial<Options>;
@@ -16,6 +29,7 @@ interface Parameters {
1629
* resizes.
1730
*/
1831
autoReposition?: boolean;
32+
customModifierOptions?: CustomModifierOptions;
1933
}
2034

2135
/**
@@ -25,7 +39,11 @@ interface Parameters {
2539
*/
2640
function buildModifiers(
2741
suppliedModifiers: Options['modifiers'],
42+
modifierOptions: CustomModifierOptions = {},
2843
): Options['modifiers'] {
44+
const matchTriggerWidthPxUpTo =
45+
modifierOptions.matchTriggerWidthPxUpTo ?? 250;
46+
2947
const defaultModifiers: Options['modifiers'] = [
3048
{
3149
name: 'flip',
@@ -50,14 +68,16 @@ function buildModifiers(
5068
phase: 'beforeWrite',
5169
requires: ['computeStyles'],
5270
fn: (obj: ModifierArguments<Record<string, unknown>>): void => {
53-
// TODO: Make the default value configurable
54-
const widthToSet = Math.min(250, obj.state.rects.reference.width);
71+
const widthToSet = Math.min(
72+
matchTriggerWidthPxUpTo,
73+
obj.state.rects.reference.width,
74+
);
5575
// eslint-disable-next-line no-param-reassign
5676
obj.state.styles.popper.minWidth = `${widthToSet}px`;
5777
},
5878
effect: (obj: ModifierArguments<Record<string, unknown>>): void => {
5979
const width = (obj.state.elements.reference as HTMLElement).offsetWidth;
60-
const widthToSet = Math.min(250, width);
80+
const widthToSet = Math.min(matchTriggerWidthPxUpTo, width);
6181
// eslint-disable-next-line no-param-reassign
6282
obj.state.elements.popper.style.minWidth = `${widthToSet}px`;
6383
},
@@ -91,7 +111,10 @@ export default function popper(
91111
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
92112
popperInstance = createPopper(reference, node, {
93113
placement: options?.placement || 'bottom-start',
94-
modifiers: buildModifiers(options?.modifiers ?? []),
114+
modifiers: buildModifiers(
115+
options?.modifiers ?? [],
116+
actionOpts.customModifierOptions,
117+
),
95118
}) as Instance;
96119

97120
if (autoReposition) {
@@ -140,3 +163,24 @@ export default function popper(
140163
destroy,
141164
};
142165
}
166+
167+
export function getVirtualReferenceElement(pointerPosition: {
168+
clientX: number;
169+
clientY: number;
170+
}) {
171+
const x = pointerPosition.clientX;
172+
const y = pointerPosition.clientY;
173+
return {
174+
getBoundingClientRect: () => ({
175+
width: 0,
176+
height: 0,
177+
x,
178+
y,
179+
top: y,
180+
right: x,
181+
bottom: y,
182+
left: x,
183+
toJSON: () => ({ x, y }),
184+
}),
185+
};
186+
}

0 commit comments

Comments
 (0)