u
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
import { isTouchDevice } from '../../utils/touch';
|
||||||
|
|
||||||
const FOCUSABLE =
|
const FOCUSABLE =
|
||||||
'a, button, input, textarea, select, [role="button"], [role="menuitem"], [tabindex]:not([tabindex="-1"]), [contenteditable="true"]';
|
'a, button, input, textarea, select, [role="button"], [role="menuitem"], [tabindex]:not([tabindex="-1"]), [contenteditable="true"]';
|
||||||
|
|
||||||
@@ -9,6 +11,7 @@ const FOCUSABLE =
|
|||||||
* focus afterwards, unless the user just selected some text.
|
* focus afterwards, unless the user just selected some text.
|
||||||
*/
|
*/
|
||||||
export function preserveSearchFocus(e) {
|
export function preserveSearchFocus(e) {
|
||||||
|
if (isTouchDevice()) return;
|
||||||
if (e.target.closest(FOCUSABLE)) return;
|
if (e.target.closest(FOCUSABLE)) return;
|
||||||
const selection = window.getSelection?.();
|
const selection = window.getSelection?.();
|
||||||
if (selection && !selection.isCollapsed) return;
|
if (selection && !selection.isCollapsed) return;
|
||||||
|
|||||||
@@ -88,9 +88,6 @@ class QuoteRow extends PureComponent {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ListItemButton disabled divider sx={rowSx}>
|
<ListItemButton disabled divider sx={rowSx}>
|
||||||
<Box sx={{ width: 48, height: 24, flexShrink: 0, display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
||||||
<Skeleton variant="rounded" width={18} height={24} />
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ width: 120, flexShrink: 0 }}>
|
<Box sx={{ width: 120, flexShrink: 0 }}>
|
||||||
<Skeleton variant="text" width={80} height={20} />
|
<Skeleton variant="text" width={80} height={20} />
|
||||||
<Skeleton variant="text" width={50} height={16} />
|
<Skeleton variant="text" width={50} height={16} />
|
||||||
@@ -104,6 +101,9 @@ class QuoteRow extends PureComponent {
|
|||||||
<Box sx={{ width: 40, flexShrink: 0, display: 'flex', justifyContent: 'center' }}>
|
<Box sx={{ width: 40, flexShrink: 0, display: 'flex', justifyContent: 'center' }}>
|
||||||
<Skeleton variant="rounded" width={28} height={20} />
|
<Skeleton variant="rounded" width={28} height={20} />
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box sx={{ width: 76, flexShrink: 0, display: 'flex', alignItems: 'center', gap: '3px' }}>
|
||||||
|
<Skeleton variant="rounded" width={15} height={20} />
|
||||||
|
</Box>
|
||||||
<Box sx={{ width: 96, flexShrink: 0, display: 'flex', justifyContent: 'flex-end' }}>
|
<Box sx={{ width: 96, flexShrink: 0, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
<Skeleton variant="text" width={60} height={20} />
|
<Skeleton variant="text" width={60} height={20} />
|
||||||
</Box>
|
</Box>
|
||||||
@@ -121,75 +121,6 @@ class QuoteRow extends PureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ListItemButton divider sx={rowSx} onClick={() => onOpen?.(quote)}>
|
<ListItemButton divider sx={rowSx} onClick={() => onOpen?.(quote)}>
|
||||||
{/* Document SkeleHash / PDF Previews (one small icon per file) */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: 48,
|
|
||||||
flexShrink: 0,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{files.map((file, index) => {
|
|
||||||
const fileId = file.fileId ?? file.id;
|
|
||||||
const title = file.name ? `${file.name} (Öffnen)` : 'Datei öffnen';
|
|
||||||
return (
|
|
||||||
<Tooltip key={file.id ?? fileId ?? index} title={title}>
|
|
||||||
<Box
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (quote.id != null && fileId != null) {
|
|
||||||
window.open(`/api/quote/${encodeURIComponent(quote.id)}/file/${encodeURIComponent(fileId)}`, '_blank');
|
|
||||||
} else {
|
|
||||||
onOpen?.(quote);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
if (quote.id != null && fileId != null) {
|
|
||||||
window.open(`/api/quote/${encodeURIComponent(quote.id)}/file/${encodeURIComponent(fileId)}`, '_blank');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
width: 18,
|
|
||||||
height: 24,
|
|
||||||
flexShrink: 0,
|
|
||||||
borderRadius: 0.5,
|
|
||||||
overflow: 'hidden',
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease',
|
|
||||||
'&:hover': {
|
|
||||||
boxShadow: '0 2px 6px rgba(0,0,0,0.22)',
|
|
||||||
borderColor: 'primary.main',
|
|
||||||
transform: 'scale(1.2)',
|
|
||||||
zIndex: 2,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{file.skeledash ? (
|
|
||||||
<SkeleHashImage hash={file.skeledash} />
|
|
||||||
) : (
|
|
||||||
<PictureAsPdfIcon sx={{ fontSize: 13, color: 'error.main' }} />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ width: 120, flexShrink: 0, minWidth: 0 }}>
|
<Box sx={{ width: 120, flexShrink: 0, minWidth: 0 }}>
|
||||||
<Typography variant="body1" sx={{ fontWeight: 600, ...ellipsisSx }}>
|
<Typography variant="body1" sx={{ fontWeight: 600, ...ellipsisSx }}>
|
||||||
{quote.number}
|
{quote.number}
|
||||||
@@ -228,6 +159,78 @@ class QuoteRow extends PureComponent {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Files / Attachments column after Pos.: space for 4 per row, 2 lines max */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 76,
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignContent: 'center',
|
||||||
|
gap: '3px',
|
||||||
|
maxHeight: 44,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{files.slice(0, 8).map((file, index) => {
|
||||||
|
const fileId = file.fileId ?? file.id;
|
||||||
|
const title = file.name ? `${file.name} (Öffnen)` : 'Datei öffnen';
|
||||||
|
return (
|
||||||
|
<Tooltip key={file.id ?? fileId ?? index} title={title}>
|
||||||
|
<Box
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (quote.id != null && fileId != null) {
|
||||||
|
window.open(`/api/quote/${encodeURIComponent(quote.id)}/file/${encodeURIComponent(fileId)}`, '_blank');
|
||||||
|
} else {
|
||||||
|
onOpen?.(quote);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
if (quote.id != null && fileId != null) {
|
||||||
|
window.open(`/api/quote/${encodeURIComponent(quote.id)}/file/${encodeURIComponent(fileId)}`, '_blank');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
width: 15,
|
||||||
|
height: 20,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: 0.5,
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease',
|
||||||
|
'&:hover': {
|
||||||
|
boxShadow: '0 2px 6px rgba(0,0,0,0.25)',
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
transform: 'scale(1.3)',
|
||||||
|
zIndex: 3,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{file.skeledash ? (
|
||||||
|
<SkeleHashImage hash={file.skeledash} />
|
||||||
|
) : (
|
||||||
|
<PictureAsPdfIcon sx={{ fontSize: 11, color: 'error.main' }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
sx={{ width: 96, flexShrink: 0, textAlign: 'right', fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}
|
sx={{ width: 96, flexShrink: 0, textAlign: 'right', fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}
|
||||||
@@ -590,11 +593,11 @@ export class QuoteList extends PureComponent {
|
|||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ width: 48, flexShrink: 0 }} />
|
|
||||||
<Box sx={{ width: 120, flexShrink: 0 }}>Nr. / Ref.</Box>
|
<Box sx={{ width: 120, flexShrink: 0 }}>Nr. / Ref.</Box>
|
||||||
<Box sx={{ width: 100, flexShrink: 0 }}>Datum</Box>
|
<Box sx={{ width: 100, flexShrink: 0 }}>Datum</Box>
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>Kunde</Box>
|
<Box sx={{ flex: 1, minWidth: 0 }}>Kunde</Box>
|
||||||
<Box sx={{ width: 40, flexShrink: 0, textAlign: 'center' }}>Pos.</Box>
|
<Box sx={{ width: 40, flexShrink: 0, textAlign: 'center' }}>Pos.</Box>
|
||||||
|
<Box sx={{ width: 76, flexShrink: 0, textAlign: 'center' }}>Dateien</Box>
|
||||||
<Box sx={{ width: 96, flexShrink: 0, textAlign: 'right' }}>Gesamt</Box>
|
<Box sx={{ width: 96, flexShrink: 0, textAlign: 'right' }}>Gesamt</Box>
|
||||||
<Box sx={{ width: 40, flexShrink: 0 }} />
|
<Box sx={{ width: 40, flexShrink: 0 }} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import AuthBootstrap from './components/AuthBootstrap';
|
|||||||
import App from './App';
|
import App from './App';
|
||||||
import { getActiveTheme } from './theme';
|
import { getActiveTheme } from './theme';
|
||||||
import { installCssVars } from './colors';
|
import { installCssVars } from './colors';
|
||||||
|
import { disableTouchAutofocus } from './utils/touch';
|
||||||
|
|
||||||
|
// Globally suppress automatic focus on touch devices to avoid virtual keyboard popups
|
||||||
|
disableTouchAutofocus();
|
||||||
|
|
||||||
// Side-effect imports: each module registers its automat and subscribes to
|
// Side-effect imports: each module registers its automat and subscribes to
|
||||||
// authAutomat, so the stores must be evaluated before the session bootstraps.
|
// authAutomat, so the stores must be evaluated before the session bootstraps.
|
||||||
import './automats';
|
import './automats';
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
NAV_ITEM_ACTIVE_TEXT,
|
NAV_ITEM_ACTIVE_TEXT,
|
||||||
} from '../../colors';
|
} from '../../colors';
|
||||||
|
|
||||||
|
import { isTouchDevice } from '../../utils/touch';
|
||||||
|
|
||||||
const nav = {
|
const nav = {
|
||||||
background: NAV_BACKGROUND,
|
background: NAV_BACKGROUND,
|
||||||
backgroundColor: NAV_SURFACE,
|
backgroundColor: NAV_SURFACE,
|
||||||
@@ -33,6 +35,7 @@ const nav = {
|
|||||||
export const defaultThemeId = 'default';
|
export const defaultThemeId = 'default';
|
||||||
|
|
||||||
export function createDefaultTheme() {
|
export function createDefaultTheme() {
|
||||||
|
const isTouch = isTouchDevice();
|
||||||
return createTheme({
|
return createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
mode: 'light',
|
mode: 'light',
|
||||||
@@ -47,11 +50,13 @@ export function createDefaultTheme() {
|
|||||||
MuiModal: {
|
MuiModal: {
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
disableScrollLock: true,
|
disableScrollLock: true,
|
||||||
|
disableAutoFocus: isTouch,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
MuiDialog: {
|
MuiDialog: {
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
disableScrollLock: true,
|
disableScrollLock: true,
|
||||||
|
disableAutoFocus: isTouch,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
MuiMenu: {
|
MuiMenu: {
|
||||||
@@ -91,6 +96,7 @@ export function createDefaultTheme() {
|
|||||||
defaultProps: {
|
defaultProps: {
|
||||||
transitionDuration: 0,
|
transitionDuration: 0,
|
||||||
disableScrollLock: true,
|
disableScrollLock: true,
|
||||||
|
disableAutoFocus: isTouch,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
55
src/utils/touch.js
Normal file
55
src/utils/touch.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Detects if the primary pointing device or environment is a touch device
|
||||||
|
* (e.g. tablet, phone, POS touchscreen terminal).
|
||||||
|
* Returns false on desktop devices with mice/trackpads even if they have touchscreens.
|
||||||
|
*/
|
||||||
|
export function isTouchDevice() {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
if (window.matchMedia) {
|
||||||
|
if (window.matchMedia('(pointer: coarse)').matches) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (window.matchMedia('(pointer: fine)').matches) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'ontouchstart' in window || (navigator.maxTouchPoints > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Globally disables autofocus on touch devices to prevent virtual keyboard popups.
|
||||||
|
* Physical taps on inputs still focus and open the keyboard normally.
|
||||||
|
*/
|
||||||
|
export function disableTouchAutofocus() {
|
||||||
|
if (!isTouchDevice()) return;
|
||||||
|
|
||||||
|
// 1. Intercept programmatic focus called during mount/render on autofocus elements
|
||||||
|
const originalFocus = HTMLElement.prototype.focus;
|
||||||
|
HTMLElement.prototype.focus = function (options) {
|
||||||
|
if (this.hasAttribute?.('autofocus') || this.autofocus) {
|
||||||
|
this.removeAttribute?.('autofocus');
|
||||||
|
try {
|
||||||
|
this.autofocus = false;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = new Error().stack || '';
|
||||||
|
if (stack.includes('commitMount') || stack.includes('commitHostUpdate')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalFocus.call(this, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Prevent browser from honoring native autofocus attribute on dynamically inserted elements
|
||||||
|
const originalSetAttribute = Element.prototype.setAttribute;
|
||||||
|
Element.prototype.setAttribute = function (name, value) {
|
||||||
|
if (typeof name === 'string' && name.toLowerCase() === 'autofocus') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return originalSetAttribute.call(this, name, value);
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user