@djangocfg/ui-core 2.1.583 → 2.1.585

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.583",
3
+ "version": "2.1.585",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -130,8 +130,8 @@
130
130
  "check:contrast": "node scripts/check-preset-contrast.mjs"
131
131
  },
132
132
  "peerDependencies": {
133
- "@djangocfg/api": "^2.1.583",
134
- "@djangocfg/i18n": "^2.1.583",
133
+ "@djangocfg/api": "^2.1.585",
134
+ "@djangocfg/i18n": "^2.1.585",
135
135
  "consola": "^3.4.2",
136
136
  "lucide-react": "^0.545.0",
137
137
  "moment": "^2.30.1",
@@ -207,10 +207,10 @@
207
207
  "vaul": "1.1.2"
208
208
  },
209
209
  "devDependencies": {
210
- "@djangocfg/api": "^2.1.583",
211
- "@djangocfg/eslint-config": "^2.1.583",
212
- "@djangocfg/i18n": "^2.1.583",
213
- "@djangocfg/typescript-config": "^2.1.583",
210
+ "@djangocfg/api": "^2.1.585",
211
+ "@djangocfg/eslint-config": "^2.1.585",
212
+ "@djangocfg/i18n": "^2.1.585",
213
+ "@djangocfg/typescript-config": "^2.1.585",
214
214
  "@storybook/react-vite": "^10.5.0",
215
215
  "@types/node": "^24.13.3",
216
216
  "@types/react": "19.2.15",
@@ -1,5 +1,11 @@
1
1
  "use client";
2
2
 
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ // Primitives
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+ export { TouchSafeInput, TouchSafeTextarea, TouchSafeSelect } from './primitives/TouchSafeInput';
7
+ export type { TouchSafeInputProps, TouchSafeTextareaProps, TouchSafeSelectProps } from './primitives/TouchSafeInput';
8
+
3
9
  // ─────────────────────────────────────────────────────────────────────────────
4
10
  // Forms
5
11
  // ─────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,160 @@
1
+ import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from 'react';
2
+ import { touchSafeStyle } from '../../utils/touch-safe';
3
+
4
+ /**
5
+ * Touch-safe input wrapper that automatically prevents iOS WebKit auto-zoom.
6
+ *
7
+ * Drop-in replacement for <input> with automatic iOS protection.
8
+ * No device detection needed, no hooks, no manual styling.
9
+ *
10
+ * **How it works**:
11
+ * - Uses CSS custom property `--touch-safe-font-size`
12
+ * - iOS: Automatically gets max(16px, 1em) via @supports
13
+ * - Android/Desktop: Uses provided fontSize or 1rem default
14
+ *
15
+ * **WCAG Compliant**: No viewport restrictions, text zoom works.
16
+ *
17
+ * @example Basic usage
18
+ * ```tsx
19
+ * import { TouchSafeInput } from '@djangocfg/ui-core/components';
20
+ *
21
+ * <TouchSafeInput type="email" placeholder="Email" />
22
+ * // iOS: font-size max(16px, 1em) → no zoom
23
+ * // Android/Desktop: font-size 1rem → design's default
24
+ * ```
25
+ *
26
+ * @example Custom design font-size
27
+ * ```tsx
28
+ * <TouchSafeInput
29
+ * type="search"
30
+ * designFontSize="0.875rem" // 14px on Android/Desktop
31
+ * />
32
+ * // iOS: max(16px, 1em)
33
+ * // Android/Desktop: 0.875rem (14px)
34
+ * ```
35
+ *
36
+ * @example With existing styles
37
+ * ```tsx
38
+ * <TouchSafeInput
39
+ * style={{ padding: '8px', border: '1px solid gray' }}
40
+ * designFontSize="14px"
41
+ * />
42
+ * // fontSize automatically added, other styles preserved
43
+ * ```
44
+ *
45
+ * @see {@link ../../utils/touch-safe.ts} Utility functions
46
+ * @see {@link ../../hooks/device/useTouchSafeStyle.ts} Hook alternative
47
+ */
48
+ export interface TouchSafeInputProps extends ComponentPropsWithoutRef<'input'> {
49
+ /**
50
+ * Design's intended font-size for Android/Desktop.
51
+ * iOS will use max(16px, 1em) regardless.
52
+ *
53
+ * @default '1rem'
54
+ */
55
+ designFontSize?: string;
56
+ }
57
+
58
+ export const TouchSafeInput = forwardRef<
59
+ ElementRef<'input'>,
60
+ TouchSafeInputProps
61
+ >(({ designFontSize = '1rem', style, ...props }, ref) => {
62
+ return (
63
+ <input
64
+ ref={ref}
65
+ {...props}
66
+ style={{
67
+ ...style,
68
+ ...touchSafeStyle(designFontSize),
69
+ }}
70
+ />
71
+ );
72
+ });
73
+
74
+ TouchSafeInput.displayName = 'TouchSafeInput';
75
+
76
+ /**
77
+ * Touch-safe textarea wrapper that automatically prevents iOS WebKit auto-zoom.
78
+ *
79
+ * Drop-in replacement for <textarea> with automatic iOS protection.
80
+ *
81
+ * @example
82
+ * ```tsx
83
+ * import { TouchSafeTextarea } from '@djangocfg/ui-core/components';
84
+ *
85
+ * <TouchSafeTextarea
86
+ * placeholder="Type a message..."
87
+ * designFontSize="14px"
88
+ * />
89
+ * ```
90
+ */
91
+ export interface TouchSafeTextareaProps extends ComponentPropsWithoutRef<'textarea'> {
92
+ /**
93
+ * Design's intended font-size for Android/Desktop.
94
+ * iOS will use max(16px, 1em) regardless.
95
+ *
96
+ * @default '1rem'
97
+ */
98
+ designFontSize?: string;
99
+ }
100
+
101
+ export const TouchSafeTextarea = forwardRef<
102
+ ElementRef<'textarea'>,
103
+ TouchSafeTextareaProps
104
+ >(({ designFontSize = '1rem', style, ...props }, ref) => {
105
+ return (
106
+ <textarea
107
+ ref={ref}
108
+ {...props}
109
+ style={{
110
+ ...style,
111
+ ...touchSafeStyle(designFontSize),
112
+ }}
113
+ />
114
+ );
115
+ });
116
+
117
+ TouchSafeTextarea.displayName = 'TouchSafeTextarea';
118
+
119
+ /**
120
+ * Touch-safe select wrapper that automatically prevents iOS WebKit auto-zoom.
121
+ *
122
+ * Drop-in replacement for <select> with automatic iOS protection.
123
+ *
124
+ * @example
125
+ * ```tsx
126
+ * import { TouchSafeSelect } from '@djangocfg/ui-core/components';
127
+ *
128
+ * <TouchSafeSelect designFontSize="14px">
129
+ * <option value="1">Option 1</option>
130
+ * <option value="2">Option 2</option>
131
+ * </TouchSafeSelect>
132
+ * ```
133
+ */
134
+ export interface TouchSafeSelectProps extends ComponentPropsWithoutRef<'select'> {
135
+ /**
136
+ * Design's intended font-size for Android/Desktop.
137
+ * iOS will use max(16px, 1em) regardless.
138
+ *
139
+ * @default '1rem'
140
+ */
141
+ designFontSize?: string;
142
+ }
143
+
144
+ export const TouchSafeSelect = forwardRef<
145
+ ElementRef<'select'>,
146
+ TouchSafeSelectProps
147
+ >(({ designFontSize = '1rem', style, ...props }, ref) => {
148
+ return (
149
+ <select
150
+ ref={ref}
151
+ {...props}
152
+ style={{
153
+ ...style,
154
+ ...touchSafeStyle(designFontSize),
155
+ }}
156
+ />
157
+ );
158
+ });
159
+
160
+ TouchSafeSelect.displayName = 'TouchSafeSelect';
@@ -3,6 +3,7 @@
3
3
  import * as React from 'react';
4
4
 
5
5
  import { cn } from '../../../lib/utils';
6
+ import { touchSafeFontSize } from '../../../utils/touch-safe';
6
7
 
7
8
  export interface TokenIconProps extends React.HTMLAttributes<HTMLDivElement> {
8
9
  symbol: string
@@ -149,6 +150,10 @@ export function TokenIcon({
149
150
  const fallbackFontSize = Math.max(8, size * 0.4)
150
151
  const truncatedSymbol = symbol?.slice(0, 3) || '?'
151
152
 
153
+ // Touch-safe font size for iOS auto-zoom prevention
154
+ // iOS: max(16px, fallbackFontSize), Android/Desktop: fallbackFontSize
155
+ const touchSafeFallbackFontSize = touchSafeFontSize(`${fallbackFontSize}px`)
156
+
152
157
  return (
153
158
  <div
154
159
  className={cn("inline-flex items-center justify-center flex-shrink-0", className)}
@@ -164,7 +169,7 @@ export function TokenIcon({
164
169
  // Fallback: show symbol as text
165
170
  <span
166
171
  className="text-muted-foreground font-medium uppercase"
167
- style={{ fontSize: fallbackFontSize }}
172
+ style={{ fontSize: touchSafeFallbackFontSize }}
168
173
  >
169
174
  {truncatedSymbol}
170
175
  </span>
@@ -5,3 +5,5 @@ export type { BrowserInfo } from './useBrowserDetect';
5
5
  export { useDeviceDetect } from './useDeviceDetect';
6
6
  export type { DeviceDetectResult } from './useDeviceDetect';
7
7
  export { useShortcutModLabel } from './useShortcutModLabel';
8
+ export { useTouchSafeStyle } from './useTouchSafeStyle';
9
+ export type { TouchSafeStyleOptions } from './useTouchSafeStyle';
@@ -0,0 +1,341 @@
1
+ # useTouchSafeStyle
2
+
3
+ **iOS WebKit auto-zoom prevention hook**
4
+
5
+ Returns touch-safe font-size for input/textarea elements to prevent iOS Safari/Chrome from zooming viewport on focus.
6
+
7
+ ---
8
+
9
+ ## Problem
10
+
11
+ iOS WebKit (Safari, Chrome, Edge) automatically zooms the viewport when an `<input>` or `<textarea>` with `font-size < 16px` receives focus. This causes:
12
+
13
+ - **UI "flying away"** — the entire page zooms in, scrolling content out of view
14
+ - **Critical UX failure** — especially in chat/conversation interfaces where context is essential
15
+ - **Disorientation** — users must manually zoom out to regain context
16
+
17
+ **Root Cause**: iOS design decision to help users read small text, but problematic for modern responsive designs.
18
+
19
+ ---
20
+
21
+ ## Solution
22
+
23
+ Use `max(16px, 1em)` to maintain relative sizing while preventing zoom:
24
+
25
+ - **16px**: iOS zoom threshold (anything ≥16px prevents auto-zoom)
26
+ - **1em**: Maintains relative sizing for accessibility (respects user's base font-size preference)
27
+ - **max()**: Takes the larger value, ensuring both goals are met
28
+
29
+ **Default Behavior**: iOS-only targeting. Android and desktop remain unaffected.
30
+
31
+ ---
32
+
33
+ ## API
34
+
35
+ ```typescript
36
+ function useTouchSafeStyle(options?: TouchSafeStyleOptions): string | undefined
37
+ ```
38
+
39
+ ### Options
40
+
41
+ ```typescript
42
+ interface TouchSafeStyleOptions {
43
+ /** Target iOS specifically (default: true) */
44
+ ios?: boolean;
45
+
46
+ /** Target all mobile devices (default: false) */
47
+ mobile?: boolean;
48
+
49
+ /** Minimum font size to prevent zoom (default: '16px') */
50
+ minSize?: string;
51
+ }
52
+ ```
53
+
54
+ ### Returns
55
+
56
+ - **iOS** (when `ios: true`, default): `"max(16px, 1em)"`
57
+ - **Android** (when `ios: true`): `undefined` (keeps optimal font-size, no zoom issue)
58
+ - **Desktop**: `undefined` (no mobile zoom behavior)
59
+ - **All mobile** (when `mobile: true`): `"max(16px, 1em)"` for iOS + Android
60
+
61
+ ---
62
+
63
+ ## Usage
64
+
65
+ ### Basic (iOS-only, recommended)
66
+
67
+ ```tsx
68
+ import { useTouchSafeStyle } from '@djangocfg/ui-core/hooks/device';
69
+
70
+ function ChatInput() {
71
+ const fontSize = useTouchSafeStyle();
72
+
73
+ return (
74
+ <textarea
75
+ style={{ fontSize }}
76
+ placeholder="Type a message..."
77
+ />
78
+ );
79
+ }
80
+ ```
81
+
82
+ **Result**:
83
+ - **iOS**: `font-size: max(16px, 1em)` — no zoom ✅
84
+ - **Android**: no inline style — keeps design's 14px ✅
85
+ - **Desktop**: no inline style — normal sizing ✅
86
+
87
+ ### All Mobile Devices
88
+
89
+ ```tsx
90
+ const fontSize = useTouchSafeStyle({ mobile: true, ios: false });
91
+
92
+ // iOS + Android both get 16px minimum
93
+ // Useful when: consistency across mobile platforms > optimal Android UX
94
+ ```
95
+
96
+ ### Custom Minimum Size
97
+
98
+ ```tsx
99
+ const fontSize = useTouchSafeStyle({ minSize: '18px' });
100
+
101
+ // Ensures font-size ≥18px on iOS
102
+ // Useful for: accessibility requirements, larger touch targets
103
+ ```
104
+
105
+ ### Conditional Fallback
106
+
107
+ ```tsx
108
+ function SearchInput() {
109
+ const touchSafeFontSize = useTouchSafeStyle();
110
+
111
+ return (
112
+ <input
113
+ type="search"
114
+ style={{
115
+ fontSize: touchSafeFontSize || '14px', // Desktop gets 14px
116
+ }}
117
+ />
118
+ );
119
+ }
120
+ ```
121
+
122
+ ### Form with Multiple Inputs
123
+
124
+ ```tsx
125
+ function LoginForm() {
126
+ const fontSize = useTouchSafeStyle();
127
+
128
+ return (
129
+ <form>
130
+ <input type="email" style={{ fontSize }} />
131
+ <input type="password" style={{ fontSize }} />
132
+ <button type="submit">Sign In</button>
133
+ </form>
134
+ );
135
+ }
136
+ ```
137
+
138
+ ---
139
+
140
+ ## CSS Alternative
141
+
142
+ For inputs that don't need programmatic control, use CSS utilities:
143
+
144
+ ```tsx
145
+ import '@djangocfg/ui-core/styles';
146
+
147
+ // Option 1: Utility class (opt-in)
148
+ <div className="touch-safe-input">
149
+ <input type="text" />
150
+ <textarea />
151
+ </div>
152
+
153
+ // Option 2: Automatic via @supports (iOS detection at build time)
154
+ // Already applied globally — inputs get max(16px, 1em) on iOS
155
+ // No class needed, works without JS
156
+ ```
157
+
158
+ **When to use CSS vs Hook**:
159
+ - **CSS**: Static inputs, no conditional logic, works without JS
160
+ - **Hook**: Dynamic styling, iOS-only precision, React-controlled components
161
+
162
+ ---
163
+
164
+ ## Why iOS-Only by Default?
165
+
166
+ **Android doesn't have auto-zoom behavior** — enforcing 16px there is unnecessary:
167
+
168
+ - ✅ **Better UX**: Android keeps design's optimal font-size (e.g., 14px for compact UI)
169
+ - ✅ **Precise targeting**: Only fixes the platform with the problem
170
+ - ✅ **Design flexibility**: Developers can choose smaller fonts on Android
171
+
172
+ **When to use `mobile: true`**:
173
+ - Consistent font-size across all mobile platforms is a requirement
174
+ - Simplicity over optimization
175
+ - Accessibility guidelines mandate 16px minimum everywhere
176
+
177
+ ---
178
+
179
+ ## WCAG Compliance
180
+
181
+ ✅ **WCAG 2.1 Level AA Compliant**
182
+
183
+ - **SC 1.4.4 Resize Text**: Text can be resized up to 200% without loss of content or functionality
184
+ - ✅ No viewport restrictions (no `user-scalable=no`)
185
+ - ✅ `max(16px, 1em)` respects user's base font-size settings
186
+ - ✅ Browser's text zoom still works
187
+
188
+ - **SC 1.4.10 Reflow**: Content reflows at 400% zoom without horizontal scrolling
189
+ - ✅ Relative `em` units maintain reflow behavior
190
+ - ✅ No fixed pixel-only sizing
191
+
192
+ **Rejected Alternative**: `viewport meta` with `user-scalable=no` ❌
193
+ ```html
194
+ <!-- ❌ WCAG violation — prevents text zoom -->
195
+ <meta name="viewport" content="user-scalable=no" />
196
+ ```
197
+
198
+ ---
199
+
200
+ ## Examples
201
+
202
+ ### Chat Composer (Critical Context)
203
+
204
+ ```tsx
205
+ function ChatComposer() {
206
+ const fontSize = useTouchSafeStyle(); // iOS-only
207
+
208
+ return (
209
+ <textarea
210
+ style={{ fontSize }}
211
+ placeholder="Type your message..."
212
+ aria-label="Message input"
213
+ />
214
+ );
215
+ }
216
+ ```
217
+
218
+ **Rationale**: Chat conversations are context-critical. iOS zoom breaks UX entirely. Android keeps optimal 14px.
219
+
220
+ ### Search Box (Optional Enhancement)
221
+
222
+ ```tsx
223
+ function SearchBar() {
224
+ const fontSize = useTouchSafeStyle();
225
+
226
+ return (
227
+ <input
228
+ type="search"
229
+ style={{ fontSize: fontSize || '15px' }} // Desktop: 15px
230
+ />
231
+ );
232
+ }
233
+ ```
234
+
235
+ **Rationale**: Search is less context-critical, but preventing zoom improves UX. Desktop gets design's preferred size.
236
+
237
+ ### Login Form (Security-Critical)
238
+
239
+ ```tsx
240
+ function PasswordField() {
241
+ const fontSize = useTouchSafeStyle({ mobile: true }); // All mobile
242
+
243
+ return (
244
+ <input
245
+ type="password"
246
+ style={{ fontSize }}
247
+ autoComplete="current-password"
248
+ />
249
+ );
250
+ }
251
+ ```
252
+
253
+ **Rationale**: Password fields benefit from larger text on all mobile devices for security/visibility.
254
+
255
+ ---
256
+
257
+ ## Implementation Details
258
+
259
+ ### How It Works
260
+
261
+ ```typescript
262
+ export function useTouchSafeStyle(options = {}) {
263
+ const { isIOS, isMobile } = useDeviceDetect();
264
+ const minSize = options.minSize || '16px';
265
+
266
+ // All mobile (iOS + Android)
267
+ if (options.mobile && isMobile) {
268
+ return `max(${minSize}, 1em)`;
269
+ }
270
+
271
+ // iOS-only (default)
272
+ if (options.ios !== false && isIOS) {
273
+ return `max(${minSize}, 1em)`;
274
+ }
275
+
276
+ // Desktop or non-targeted mobile
277
+ return undefined;
278
+ }
279
+ ```
280
+
281
+ ### Detection Method
282
+
283
+ Uses `useDeviceDetect()` hook (from `react-device-detect`):
284
+ - **iOS**: Detects iOS Safari, Chrome, Edge, Firefox
285
+ - **Android**: Detects Android browsers
286
+ - **Desktop**: Excludes non-mobile devices
287
+ - **SSR-safe**: Returns default values server-side, updates client-side
288
+
289
+ ### Performance
290
+
291
+ - **Zero runtime cost on desktop** — returns `undefined`, no style applied
292
+ - **Minimal on mobile** — one device detect call (cached), simple string return
293
+ - **No layout shift** — applied on mount, before first paint
294
+
295
+ ---
296
+
297
+ ## Related
298
+
299
+ - **CSS Utilities**: `@djangocfg/ui-core/styles/utilities/touch-safe.css`
300
+ - **Research**: `@researches/2026-10-25__ios-webkit-auto-zoom/`
301
+ - **Widget Implementation**: `@djangocfg/widget-chat` (auto-applied)
302
+ - **Device Detection**: `useDeviceDetect()` hook
303
+
304
+ ---
305
+
306
+ ## References
307
+
308
+ - [Apple: Configuring the Viewport](https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/AdjustingtheTextSize/AdjustingtheTextSize.html)
309
+ - [MDN: viewport meta tag](https://developer.mozilla.org/en-US/docs/Web/HTML/Viewport_meta_tag)
310
+ - [WCAG 2.1: Success Criterion 1.4.4](https://www.w3.org/WAI/WCAG21/Understanding/resize-text.html)
311
+ - [WCAG 2.1: Success Criterion 1.4.10](https://www.w3.org/WAI/WCAG21/Understanding/reflow.html)
312
+
313
+ ---
314
+
315
+ ## Migration from Viewport Meta
316
+
317
+ **Before** (❌ WCAG violation):
318
+ ```tsx
319
+ // layout.tsx
320
+ export const viewport = {
321
+ maximumScale: 1,
322
+ userScalable: false, // ❌ Prevents text zoom
323
+ };
324
+ ```
325
+
326
+ **After** (✅ WCAG compliant):
327
+ ```tsx
328
+ // Remove viewport restrictions entirely
329
+ // Use useTouchSafeStyle() in components instead
330
+
331
+ function MyInput() {
332
+ const fontSize = useTouchSafeStyle();
333
+ return <input style={{ fontSize }} />;
334
+ }
335
+ ```
336
+
337
+ **Benefits**:
338
+ - ✅ WCAG 2.1 AA compliant
339
+ - ✅ Text zoom works
340
+ - ✅ No global viewport restrictions
341
+ - ✅ iOS-only targeting (better Android UX)
@@ -0,0 +1,112 @@
1
+ 'use client';
2
+
3
+ import { useDeviceDetect } from './useDeviceDetect';
4
+
5
+ export interface TouchSafeStyleOptions {
6
+ /**
7
+ * Target iOS specifically (default: true)
8
+ *
9
+ * When enabled, returns font-size only for iOS devices.
10
+ * Android and other platforms return undefined.
11
+ */
12
+ ios?: boolean;
13
+
14
+ /**
15
+ * Target all mobile devices (default: false)
16
+ *
17
+ * When enabled, returns font-size for all mobile devices,
18
+ * not just iOS. Overrides `ios` option.
19
+ */
20
+ mobile?: boolean;
21
+
22
+ /**
23
+ * Minimum font size to prevent zoom (default: '16px')
24
+ *
25
+ * iOS WebKit zooms when font-size < 16px, but you can set
26
+ * a higher threshold if needed (e.g., '18px').
27
+ */
28
+ minSize?: string;
29
+ }
30
+
31
+ /**
32
+ * Returns touch-safe font-size style for iOS WebKit auto-zoom prevention.
33
+ *
34
+ * **Problem**: iOS Safari/Chrome zooms viewport when font-size < 16px on input focus,
35
+ * causing UI elements to "fly away" (especially critical in chat/conversation interfaces).
36
+ *
37
+ * **Solution**: Use max(16px, 1em) to maintain relative sizing while preventing zoom.
38
+ *
39
+ * **WCAG Compliance**: ✅ WCAG 2.1 AA compliant (SC 1.4.4, 1.4.10)
40
+ * - Text zoom still works (no viewport user-scalable=no)
41
+ * - No reflow issues
42
+ * - Maintains 200% text zoom capability
43
+ *
44
+ * **Default Behavior**: iOS-only targeting (Android keeps optimal font-size)
45
+ *
46
+ * @param options - Configuration options
47
+ * @returns Font-size CSS value string or undefined if not applicable
48
+ *
49
+ * @example
50
+ * ```tsx
51
+ * // iOS-only (default) — Android keeps 14px, iOS gets 16px
52
+ * function ChatInput() {
53
+ * const fontSize = useTouchSafeStyle();
54
+ * return <textarea style={{ fontSize }} placeholder="Type a message..." />;
55
+ * }
56
+ * ```
57
+ *
58
+ * @example
59
+ * ```tsx
60
+ * // All mobile devices
61
+ * const fontSize = useTouchSafeStyle({ mobile: true, ios: false });
62
+ * ```
63
+ *
64
+ * @example
65
+ * ```tsx
66
+ * // Custom minimum size
67
+ * const fontSize = useTouchSafeStyle({ minSize: '18px' });
68
+ * ```
69
+ *
70
+ * @example
71
+ * ```tsx
72
+ * // Conditional application
73
+ * function SearchInput() {
74
+ * const fontSize = useTouchSafeStyle();
75
+ *
76
+ * return (
77
+ * <input
78
+ * type="search"
79
+ * style={{
80
+ * fontSize: fontSize || '14px', // Fallback for desktop
81
+ * }}
82
+ * />
83
+ * );
84
+ * }
85
+ * ```
86
+ *
87
+ * @see {@link https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/AdjustingtheTextSize/AdjustingtheTextSize.html}
88
+ * @see {@link @researches/2026-10-25__ios-webkit-auto-zoom} Research documentation
89
+ * @see {@link ../../styles/css/utilities/touch-safe.css} CSS utilities
90
+ */
91
+ export function useTouchSafeStyle(options: TouchSafeStyleOptions = {}): string | undefined {
92
+ const { isIOS, isMobile } = useDeviceDetect();
93
+
94
+ const minSize = options.minSize || '16px';
95
+
96
+ // Default: iOS-only targeting
97
+ const targetIOS = options.ios !== false;
98
+ const targetMobile = options.mobile === true;
99
+
100
+ // Mobile override (all mobile devices)
101
+ if (targetMobile && isMobile) {
102
+ return `max(${minSize}, 1em)`;
103
+ }
104
+
105
+ // iOS-specific targeting (default)
106
+ if (targetIOS && isIOS) {
107
+ return `max(${minSize}, 1em)`;
108
+ }
109
+
110
+ // Desktop or non-targeted mobile (Android when ios: true)
111
+ return undefined;
112
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Touch-Safe Input Utilities
3
+ *
4
+ * Prevents iOS WebKit auto-zoom on input/textarea focus by enforcing 16px minimum font-size.
5
+ *
6
+ * **Problem**: iOS Safari/Chrome zooms viewport when font-size < 16px on focus,
7
+ * causing UI elements to "fly away" (especially critical in chat/conversation interfaces).
8
+ *
9
+ * **Solution**: Use max(16px, 1em) to maintain relative sizing while preventing zoom.
10
+ *
11
+ * **Universal Application**: CSS custom property `--touch-safe-font-size` is set on :root
12
+ * for iOS devices. Components can reference this variable to get automatic iOS fix without
13
+ * explicit hook calls.
14
+ *
15
+ * **WCAG Compliance**: ✅ WCAG 2.1 AA compliant (SC 1.4.4, 1.4.10)
16
+ * - Text zoom still works (no viewport user-scalable=no)
17
+ * - No reflow issues
18
+ * - Maintains 200% text zoom capability
19
+ *
20
+ * @see {@link ../../hooks/input/useTouchSafeStyle.ts} React hook for programmatic control
21
+ * @see {@link @researches/2026-10-25__ios-webkit-auto-zoom} Research documentation
22
+ */
23
+
24
+ /* ============================================================================
25
+ CSS Custom Property (Universal iOS Fix)
26
+ ========================================================================= */
27
+
28
+ /**
29
+ * Global CSS variable for touch-safe font sizing.
30
+ *
31
+ * Set on :root for iOS devices only via @supports detection.
32
+ * Components can reference var(--touch-safe-font-size, fallback) to get
33
+ * automatic iOS protection without explicit hook usage.
34
+ *
35
+ * iOS: 16px minimum (prevents auto-zoom)
36
+ * Android/Desktop: undefined (use component's design font-size)
37
+ *
38
+ * @example Component usage:
39
+ * .my-input {
40
+ * font-size: var(--touch-safe-font-size, 0.875rem);
41
+ * }
42
+ * // iOS: max(16px, 1em)
43
+ * // Android/Desktop: 0.875rem (14px)
44
+ */
45
+ :root {
46
+ --touch-safe-font-size: initial; /* Desktop/Android default */
47
+ }
48
+
49
+ @supports (-webkit-touch-callout: none) {
50
+ :root {
51
+ --touch-safe-font-size: max(16px, 1em);
52
+ }
53
+ }
54
+
55
+ /* ============================================================================
56
+ Utility Class (Opt-In)
57
+ ========================================================================= */
58
+
59
+ /**
60
+ * Apply to any container with inputs/textareas that need touch-safe sizing.
61
+ *
62
+ * @example
63
+ * <form className="touch-safe-input">
64
+ * <input type="text" />
65
+ * <textarea />
66
+ * </form>
67
+ */
68
+ .touch-safe-input input,
69
+ .touch-safe-input textarea,
70
+ .touch-safe-input select {
71
+ font-size: max(16px, 1em);
72
+ }
73
+
74
+ /* ============================================================================
75
+ iOS-Specific Auto-Fix (@supports detection)
76
+ ========================================================================= */
77
+
78
+ /**
79
+ * Targets iOS WebKit devices via CSS feature detection.
80
+ * More reliable than user-agent sniffing, works at build time.
81
+ *
82
+ * Note: Also matches desktop Safari on macOS, but 16px is acceptable there
83
+ * (no zoom behavior). For precise iOS-only targeting, use useTouchSafeStyle hook.
84
+ */
85
+ @supports (-webkit-touch-callout: none) {
86
+ input[type="text"],
87
+ input[type="email"],
88
+ input[type="search"],
89
+ input[type="tel"],
90
+ input[type="url"],
91
+ input[type="password"],
92
+ input[type="number"],
93
+ textarea,
94
+ select {
95
+ font-size: max(16px, 1em);
96
+ }
97
+
98
+ /* Prevent iOS text size adjustment during orientation changes */
99
+ html {
100
+ -webkit-text-size-adjust: 100%;
101
+ text-size-adjust: 100%;
102
+ }
103
+ }
104
+
105
+ /* ============================================================================
106
+ Mobile Baseline (Opt-In via class)
107
+ ========================================================================= */
108
+
109
+ /**
110
+ * Apply to containers for mobile-wide touch-safe inputs (iOS + Android).
111
+ *
112
+ * Note: Android doesn't have the same zoom behavior, so this is only needed
113
+ * if you want consistent 16px across all mobile devices. For iOS-only,
114
+ * prefer @supports above or useTouchSafeStyle({ ios: true }) hook.
115
+ *
116
+ * @example
117
+ * <div className="touch-safe-mobile">
118
+ * <input type="text" />
119
+ * </div>
120
+ */
121
+ @media (max-width: 768px) {
122
+ .touch-safe-mobile input,
123
+ .touch-safe-mobile textarea,
124
+ .touch-safe-mobile select {
125
+ font-size: max(16px, 1em);
126
+ }
127
+ }
128
+
129
+ /* ============================================================================
130
+ Touch Target Size (44px minimum for WCAG)
131
+ ========================================================================= */
132
+
133
+ /**
134
+ * Ensures minimum 44×44px touch targets for mobile accessibility.
135
+ *
136
+ * WCAG 2.1 Success Criterion 2.5.5 (Level AAA): Target Size
137
+ * iOS Human Interface Guidelines: 44pt minimum
138
+ *
139
+ * @example
140
+ * <button className="touch-target">Click Me</button>
141
+ */
142
+ .touch-target {
143
+ min-height: 44px;
144
+ min-width: 44px;
145
+ }
146
+
147
+ @media (max-width: 768px) {
148
+ /* Enforce on mobile for buttons, links with onClick, form controls */
149
+ button,
150
+ [role="button"],
151
+ a[onClick],
152
+ input[type="submit"],
153
+ input[type="button"],
154
+ input[type="reset"],
155
+ select {
156
+ min-height: 44px;
157
+ min-width: 44px;
158
+ }
159
+
160
+ /* Special case: Icon-only buttons */
161
+ button[aria-label]:not(:has(*)),
162
+ [role="button"][aria-label]:not(:has(*)) {
163
+ min-height: 44px;
164
+ min-width: 44px;
165
+ }
166
+ }
@@ -17,3 +17,4 @@
17
17
  @import './utilities/animations.css';
18
18
  @import './utilities/glass.css';
19
19
  @import './utilities/marquee.css';
20
+ @import './utilities/touch-safe.css';
@@ -1 +1,8 @@
1
1
  export { emitRuntimeError, RUNTIME_ERROR_EVENT } from './runtime-errors';
2
+ export {
3
+ touchSafeFontSize,
4
+ touchSafeStyle,
5
+ TOUCH_SAFE_INPUT_CLASS,
6
+ TOUCH_SAFE_MOBILE_CLASS,
7
+ TOUCH_TARGET_CLASS,
8
+ } from './touch-safe';
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Touch-safe font-size utilities for universal iOS auto-zoom prevention.
3
+ *
4
+ * Provides CSS and JavaScript helpers that automatically protect against
5
+ * iOS WebKit viewport zoom on input focus, without requiring explicit
6
+ * device detection in every component.
7
+ *
8
+ * @see {@link ../styles/css/utilities/touch-safe.css} CSS implementation
9
+ * @see {@link ../hooks/device/useTouchSafeStyle.ts} React hook alternative
10
+ * @see {@link @researches/2026-10-25__ios-webkit-auto-zoom} Research
11
+ */
12
+
13
+ /**
14
+ * Returns touch-safe font-size value that works universally.
15
+ *
16
+ * Uses CSS custom property `--touch-safe-font-size` which is automatically
17
+ * set to `max(16px, 1em)` on iOS via @supports detection.
18
+ *
19
+ * **Automatic behavior**:
20
+ * - iOS: `max(16px, 1em)` (prevents auto-zoom)
21
+ * - Android/Desktop: fallback value (design's intended size)
22
+ *
23
+ * @param fallback - Fallback font-size for Android/Desktop (default: '1rem')
24
+ * @returns CSS font-size value string
25
+ *
26
+ * @example Inline style
27
+ * ```tsx
28
+ * import { touchSafeFontSize } from '@djangocfg/ui-core/utils';
29
+ *
30
+ * <input style={{ fontSize: touchSafeFontSize('0.875rem') }} />
31
+ * // iOS: max(16px, 1em)
32
+ * // Android/Desktop: 0.875rem (14px)
33
+ * ```
34
+ *
35
+ * @example CSS-in-JS
36
+ * ```tsx
37
+ * const inputStyles = {
38
+ * fontSize: touchSafeFontSize('14px'),
39
+ * };
40
+ * ```
41
+ *
42
+ * @example Tailwind arbitrary value
43
+ * ```tsx
44
+ * <input className={`text-[${touchSafeFontSize('14px')}]`} />
45
+ * ```
46
+ */
47
+ export function touchSafeFontSize(fallback: string = '1rem'): string {
48
+ return `var(--touch-safe-font-size, ${fallback})`;
49
+ }
50
+
51
+ /**
52
+ * Returns touch-safe style object for inline styles.
53
+ *
54
+ * Convenience wrapper around touchSafeFontSize() that returns
55
+ * a complete style object ready for React inline styles.
56
+ *
57
+ * @param fallback - Fallback font-size for Android/Desktop
58
+ * @returns Style object with fontSize property
59
+ *
60
+ * @example
61
+ * ```tsx
62
+ * import { touchSafeStyle } from '@djangocfg/ui-core/utils';
63
+ *
64
+ * <textarea style={touchSafeStyle('14px')} />
65
+ * // iOS: { fontSize: 'max(16px, 1em)' }
66
+ * // Android/Desktop: { fontSize: '14px' }
67
+ * ```
68
+ *
69
+ * @example Merge with existing styles
70
+ * ```tsx
71
+ * <input
72
+ * style={{
73
+ * ...myStyles,
74
+ * ...touchSafeStyle('0.875rem'),
75
+ * }}
76
+ * />
77
+ * ```
78
+ */
79
+ export function touchSafeStyle(fallback: string = '1rem'): { fontSize: string } {
80
+ return {
81
+ fontSize: touchSafeFontSize(fallback),
82
+ };
83
+ }
84
+
85
+ /**
86
+ * CSS class name for touch-safe inputs (opt-in).
87
+ *
88
+ * Apply to a container to make all child inputs/textareas touch-safe
89
+ * on mobile devices.
90
+ *
91
+ * @example
92
+ * import { TOUCH_SAFE_INPUT_CLASS } from '@djangocfg/ui-core/utils';
93
+ * // className={TOUCH_SAFE_INPUT_CLASS}
94
+ */
95
+ export const TOUCH_SAFE_INPUT_CLASS = 'touch-safe-input';
96
+
97
+ /**
98
+ * CSS class name for mobile touch-safe inputs (iOS + Android).
99
+ *
100
+ * Apply when you want consistent 16px minimum across all mobile
101
+ * platforms, not just iOS.
102
+ *
103
+ * @example
104
+ * import { TOUCH_SAFE_MOBILE_CLASS } from '@djangocfg/ui-core/utils';
105
+ * // className={TOUCH_SAFE_MOBILE_CLASS}
106
+ */
107
+ export const TOUCH_SAFE_MOBILE_CLASS = 'touch-safe-mobile';
108
+
109
+ /**
110
+ * CSS class name for minimum 44px touch targets (WCAG AAA).
111
+ *
112
+ * @example
113
+ * import { TOUCH_TARGET_CLASS } from '@djangocfg/ui-core/utils';
114
+ * // className={TOUCH_TARGET_CLASS}
115
+ */
116
+ export const TOUCH_TARGET_CLASS = 'touch-target';