@rdlabo/ionic-theme-ios27 0.2.1 → 1.0.1-beta.pr194.sha00da872ba249

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.
Files changed (34) hide show
  1. package/README.md +134 -122
  2. package/dist/css/components/ion-segment.css +1 -1
  3. package/dist/css/components/ion-toolbar.css +1 -1
  4. package/dist/css/ionic-theme-ios27-dark-always.css +1 -1
  5. package/dist/css/ionic-theme-ios27-dark-class.css +1 -1
  6. package/dist/css/ionic-theme-ios27-dark-system.css +1 -1
  7. package/dist/css/ionic-theme-ios27.css +1 -1
  8. package/dist/css/utils/structured-list.css +1 -0
  9. package/dist/native/components/searchable-tabs.d.ts +2 -0
  10. package/dist/native/components/searchable-tabs.d.ts.map +1 -1
  11. package/dist/native/components/searchable-tabs.js +131 -21
  12. package/dist/native/runtime.d.ts.map +1 -1
  13. package/dist/native/runtime.js +14 -7
  14. package/docs/migration.md +105 -3
  15. package/docs/native-ui-shell.md +15 -3
  16. package/docs/special-markup.md +9 -0
  17. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellSearchController.swift +243 -64
  18. package/ios/Sources/IonicNativeUIShellPlugin/Components/ShellTabBar.swift +77 -6
  19. package/ios/Sources/IonicNativeUIShellPlugin/IonicNativeUIShellPlugin.swift +79 -12
  20. package/ios/Tests/IonicNativeUIShellPluginTests/ShellSnapshotTests.swift +258 -2
  21. package/node_modules/@rdlabo/ionic-theme-utils/package.json +4 -0
  22. package/node_modules/@rdlabo/ionic-theme-utils/scripts/copy-structured-list.mjs +17 -0
  23. package/package.json +4 -3
  24. package/src/native/components/searchable-tabs.ts +142 -22
  25. package/src/native/runtime.ts +11 -6
  26. package/src/styles/components/ion-list.scss +1 -1
  27. package/src/styles/components/ion-segment.scss +19 -0
  28. package/src/styles/components/ion-toolbar.scss +11 -0
  29. package/src/styles/md-ion-list-inset.scss +1 -1
  30. package/src/styles/utils/dark/ion-list.scss +1 -1
  31. package/src/styles/utils/dark/ion-segment.scss +20 -0
  32. package/src/styles/utils/structured-list.scss +310 -0
  33. package/docs/ios-adaptive.md +0 -90
  34. /package/docs/{experimental-animation.md → animation.md} +0 -0
@@ -0,0 +1 @@
1
+
@@ -6,7 +6,9 @@ export declare const createSearchSupport: (doc: Document, id: (element: Element)
6
6
  keepSearchTabsVisible(): void;
7
7
  decorate(candidates: Candidate[], blocked: (element: HTMLElement) => boolean): Candidate[];
8
8
  projected: (binding: NativeSearchBinding) => boolean;
9
+ hasActive(): boolean;
9
10
  begin(binding: NativeSearchBinding, revision: number): void;
11
+ setStatus(binding: NativeSearchBinding, active: boolean, focused?: boolean): void;
10
12
  release(element: HTMLElement): void;
11
13
  reject(ids: string[]): void;
12
14
  event(event: ShellSearchEvent): void;
@@ -1 +1 @@
1
- {"version":3,"file":"searchable-tabs.d.ts","sourceRoot":"","sources":["../../../src/native/components/searchable-tabs.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAGpE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,KAAK,EAA0B,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAmB/E,eAAO,MAAM,mBAAmB,GAAI,KAAK,QAAQ,EAAE,IAAI,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,EAAE,UAAU,MAAM,IAAI;sBA8FnF,OAAO;;yBAgBJ,SAAS,EAAE,WAAW,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,GAAG,SAAS,EAAE;yBA3GhE,mBAAmB;mBAuO9B,mBAAmB,YAAY,MAAM;qBAInC,WAAW;gBAGhB,MAAM,EAAE;iBAUP,gBAAgB;oBAqCb,mBAAmB;;CAkBtC,CAAC"}
1
+ {"version":3,"file":"searchable-tabs.d.ts","sourceRoot":"","sources":["../../../src/native/components/searchable-tabs.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAGpE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,KAAK,EAA0B,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAmB/E,eAAO,MAAM,mBAAmB,GAAI,KAAK,QAAQ,EAAE,IAAI,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,EAAE,UAAU,MAAM,IAAI;sBA8InF,OAAO;;yBAmBJ,SAAS,EAAE,WAAW,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,GAAG,SAAS,EAAE;yBAlJhE,mBAAmB;;mBAuU9B,mBAAmB,YAAY,MAAM;uBAKjC,mBAAmB,UAAU,OAAO;qBAUtC,WAAW;gBAGhB,MAAM,EAAE;iBASP,gBAAgB;oBAqCb,mBAAmB;;CAoBtC,CAAC"}
@@ -4,7 +4,52 @@ import { iconSource } from '../shared/icons';
4
4
  export const createSearchSupport = (doc, id, schedule) => {
5
5
  const states = new Map();
6
6
  let keyboardVisible = false;
7
+ let heldKeyboardResize = null;
8
+ let keyboardResizeGeneration = 0;
9
+ const keyboardPlugin = () => doc.defaultView?.Capacitor?.Plugins?.Keyboard;
7
10
  const projected = (binding) => binding.footer.hasAttribute(marker);
11
+ const holdKeyboardResize = () => {
12
+ const keyboard = keyboardPlugin();
13
+ if (!keyboard?.setResizeMode || heldKeyboardResize !== null)
14
+ return;
15
+ const setResizeMode = keyboard.setResizeMode.bind(keyboard);
16
+ const generation = ++keyboardResizeGeneration;
17
+ heldKeyboardResize = 'native';
18
+ void (async () => {
19
+ let restoreMode = 'native';
20
+ try {
21
+ const current = await keyboard.getResizeMode?.();
22
+ if (typeof current?.mode === 'string')
23
+ restoreMode = current.mode;
24
+ }
25
+ catch {
26
+ }
27
+ if (generation !== keyboardResizeGeneration)
28
+ return;
29
+ heldKeyboardResize = restoreMode;
30
+ try {
31
+ await setResizeMode({ mode: 'none' });
32
+ }
33
+ catch {
34
+ if (generation === keyboardResizeGeneration)
35
+ heldKeyboardResize = null;
36
+ return;
37
+ }
38
+ if (generation !== keyboardResizeGeneration)
39
+ void setResizeMode({ mode: restoreMode }).catch(() => undefined);
40
+ })();
41
+ };
42
+ const hasActiveProjection = () => [...states.keys()].some((binding) => binding.active && projected(binding));
43
+ const releaseKeyboardResize = () => {
44
+ if (hasActiveProjection())
45
+ return;
46
+ keyboardResizeGeneration++;
47
+ const mode = heldKeyboardResize;
48
+ heldKeyboardResize = null;
49
+ if (!mode)
50
+ return;
51
+ void keyboardPlugin()?.setResizeMode?.({ mode });
52
+ };
8
53
  const isCurrent = (state) => state.bar.isConnected &&
9
54
  state.binding.footer.querySelector('ion-searchbar') === state.bar &&
10
55
  state.bar.querySelector('input.searchbar-input') === state.input;
@@ -105,8 +150,10 @@ export const createSearchSupport = (doc, id, schedule) => {
105
150
  return {
106
151
  keyboard(visible) {
107
152
  keyboardVisible = visible;
108
- if (!visible)
153
+ if (!visible) {
109
154
  states.forEach((state) => (state.keyboardHidden = false));
155
+ releaseKeyboardResize();
156
+ }
110
157
  schedule();
111
158
  },
112
159
  keepSearchTabsVisible() {
@@ -130,6 +177,11 @@ export const createSearchSupport = (doc, id, schedule) => {
130
177
  }
131
178
  }
132
179
  const inactive = [];
180
+ const pageHidden = (element) => !!element.closest('.ion-page-hidden, .ion-page-invisible');
181
+ const retainInactive = (candidate, state, keepAvailable = false) => {
182
+ if (state?.last)
183
+ inactive.push([candidate, { ...state.last, active: false, available: keepAvailable, focused: false }]);
184
+ };
133
185
  for (const binding of bindings) {
134
186
  const candidate = candidates.find((c) => c.element === binding.tabBar);
135
187
  if (!candidate || candidate.control.search || binding.tabBar.getAttribute('slot') !== 'bottom')
@@ -140,10 +192,36 @@ export const createSearchSupport = (doc, id, schedule) => {
140
192
  continue;
141
193
  if (isShellDisabled(fab) || isShellDisabled(binding.footer))
142
194
  continue;
143
- if (blocked(binding.footer) || !visible(binding.trigger) || binding.footer.closest(excluded)) {
144
- const state = states.get(binding);
145
- if (state?.last)
146
- inactive.push([candidate, { ...state.last, active: false, available: false, focused: false }]);
195
+ const pageUnavailable = blocked(binding.footer) || !!binding.footer.closest(excluded);
196
+ const existing = states.get(binding);
197
+ if (pageUnavailable) {
198
+ retainInactive(candidate, existing, !!existing?.last?.available && !pageHidden(binding.footer));
199
+ if (existing?.last)
200
+ continue;
201
+ }
202
+ if (binding.active && existing && isCurrent(existing) && existing.last && !pageUnavailable) {
203
+ const back = binding.footer.querySelector('ion-buttons[slot=start] ion-button');
204
+ if (back && !back.disabled && !back.closest(excluded)) {
205
+ candidate.control.search = {
206
+ ...existing.last,
207
+ active: true,
208
+ available: true,
209
+ focused: binding.focused,
210
+ value: existing.bar.value ?? '',
211
+ placeholder: existing.bar.placeholder ?? '',
212
+ disabled: existing.bar.disabled,
213
+ editSequence: existing.editSequence,
214
+ valueVersion: existing.valueVersion,
215
+ };
216
+ existing.last = candidate.control.search;
217
+ candidate.sources = [binding.tabBar, binding.trigger.closest('ion-fab') ?? binding.trigger, binding.footer];
218
+ candidate.actions.set(existing.last.trigger.id, binding.trigger);
219
+ candidate.actions.set(existing.last.closeId, back);
220
+ continue;
221
+ }
222
+ }
223
+ if (!visible(binding.trigger) && !pageUnavailable) {
224
+ retainInactive(candidate, existing);
147
225
  continue;
148
226
  }
149
227
  const bar = binding.footer.querySelector('ion-searchbar');
@@ -155,10 +233,12 @@ export const createSearchSupport = (doc, id, schedule) => {
155
233
  !binding.footer.matches('ion-footer') ||
156
234
  !inFixedToolbar(bar) ||
157
235
  !inFixedToolbar(back) ||
158
- bar.closest(excluded) ||
236
+ (!pageUnavailable && bar.closest(excluded)) ||
159
237
  !bar.classList.contains('ios') ||
160
- bar.classList.contains('searchbar-classic'))
238
+ bar.classList.contains('searchbar-classic')) {
239
+ retainInactive(candidate, existing);
161
240
  continue;
241
+ }
162
242
  if (bar.color ||
163
243
  back.fill !== 'default' ||
164
244
  back.color ||
@@ -171,14 +251,18 @@ export const createSearchSupport = (doc, id, schedule) => {
171
251
  ![false, 'off', undefined].includes(bar.autocorrect) ||
172
252
  bar.spellcheck ||
173
253
  bar.showClearButton !== 'always' ||
174
- bar.clearIcon)
254
+ bar.clearIcon) {
255
+ retainInactive(candidate, existing);
175
256
  continue;
176
- if (bar.showCancelButton !== 'never' || bar.type !== 'search' || !getComputedStyle(input).backdropFilter.includes('blur'))
257
+ }
258
+ if (bar.showCancelButton !== 'never' || bar.type !== 'search' || !getComputedStyle(input).backdropFilter.includes('blur')) {
259
+ retainInactive(candidate, existing);
177
260
  continue;
261
+ }
178
262
  const fabButton = binding.trigger;
179
263
  const nativeTrigger = fabButton.shadowRoot?.querySelector('[part=native]');
180
- if (binding.trigger.closest(excluded) ||
181
- back.closest(excluded) ||
264
+ if ((!pageUnavailable && binding.trigger.closest(excluded)) ||
265
+ (!pageUnavailable && back.closest(excluded)) ||
182
266
  back.disabled ||
183
267
  fabButton.disabled ||
184
268
  fabButton.color ||
@@ -186,16 +270,20 @@ export const createSearchSupport = (doc, id, schedule) => {
186
270
  fabButton.href ||
187
271
  !nativeTrigger ||
188
272
  getComputedStyle(fabButton).backgroundColor !== 'rgba(0, 0, 0, 0)' ||
189
- !getComputedStyle(nativeTrigger).backdropFilter.includes('blur'))
273
+ !getComputedStyle(nativeTrigger).backdropFilter.includes('blur')) {
274
+ retainInactive(candidate, existing);
190
275
  continue;
276
+ }
277
+ const state = existing ?? install(binding, bar, input);
191
278
  const trigger = readIcon(binding.trigger, binding.trigger, candidate);
192
279
  const searchIcon = bar.querySelector('.searchbar-search-icon');
193
280
  const field = searchIcon && readIcon(searchIcon, bar, candidate);
194
- if (!trigger || !field)
281
+ if (!trigger || !field) {
282
+ retainInactive(candidate, state);
195
283
  continue;
284
+ }
196
285
  field.label = '';
197
286
  field.accessibilityLabel = bar.getAttribute('aria-label') ?? input.getAttribute('aria-label') ?? 'Search';
198
- const state = states.get(binding) ?? install(binding, bar, input);
199
287
  state.layout = JSON.stringify([
200
288
  innerWidth,
201
289
  innerHeight,
@@ -215,16 +303,19 @@ export const createSearchSupport = (doc, id, schedule) => {
215
303
  bar.placeholder,
216
304
  bar.disabled,
217
305
  ]);
218
- if (state.rejectedLayout === state.layout)
306
+ if (state.rejectedLayout === state.layout) {
307
+ retainInactive(candidate, state);
219
308
  continue;
309
+ }
310
+ const available = !pageUnavailable && visible(binding.trigger);
220
311
  candidate.control.search = {
221
312
  id: id(input),
222
313
  field,
223
314
  trigger,
224
315
  closeId: id(back),
225
- active: binding.active,
226
- available: true,
227
- focused: binding.focused,
316
+ active: available && binding.active,
317
+ available,
318
+ focused: available && binding.focused,
228
319
  value: bar.value ?? '',
229
320
  placeholder: bar.placeholder ?? '',
230
321
  disabled: bar.disabled,
@@ -232,9 +323,11 @@ export const createSearchSupport = (doc, id, schedule) => {
232
323
  valueVersion: state.valueVersion,
233
324
  };
234
325
  state.last = candidate.control.search;
235
- candidate.sources = [binding.tabBar, binding.trigger.closest('ion-fab') ?? binding.trigger, binding.footer];
236
- candidate.actions.set(id(binding.trigger), binding.trigger);
237
- candidate.actions.set(id(back), back);
326
+ if (available) {
327
+ candidate.sources = [binding.tabBar, binding.trigger.closest('ion-fab') ?? binding.trigger, binding.footer];
328
+ candidate.actions.set(id(binding.trigger), binding.trigger);
329
+ candidate.actions.set(id(back), back);
330
+ }
238
331
  }
239
332
  for (const [candidate, configuration] of inactive)
240
333
  (_a = candidate.control).search ?? (_a.search = configuration);
@@ -242,11 +335,26 @@ export const createSearchSupport = (doc, id, schedule) => {
242
335
  return candidates.filter((c) => !groups.some((group) => group !== c && group.sources?.some((source) => source.contains(c.element))));
243
336
  },
244
337
  projected,
338
+ hasActive() {
339
+ for (const [binding] of states)
340
+ if (binding.active && projected(binding))
341
+ return true;
342
+ return false;
343
+ },
245
344
  begin(binding, revision) {
246
345
  const state = states.get(binding);
247
346
  if (state)
248
347
  state.minimumRevision = revision;
249
348
  },
349
+ setStatus(binding, active, focused = false) {
350
+ if (!active) {
351
+ this.retire(binding);
352
+ return;
353
+ }
354
+ binding.active = true;
355
+ binding.focused = focused;
356
+ holdKeyboardResize();
357
+ },
250
358
  release(element) {
251
359
  for (const binding of states.keys())
252
360
  if (binding.footer === element)
@@ -309,6 +417,7 @@ export const createSearchSupport = (doc, id, schedule) => {
309
417
  state.input.dispatchEvent(new FocusEvent('blur'));
310
418
  }
311
419
  binding.active = binding.focused = false;
420
+ releaseKeyboardResize();
312
421
  },
313
422
  destroy() {
314
423
  states.forEach((state) => {
@@ -316,6 +425,7 @@ export const createSearchSupport = (doc, id, schedule) => {
316
425
  state.restore();
317
426
  });
318
427
  states.clear();
428
+ releaseKeyboardResize();
319
429
  },
320
430
  };
321
431
  };
@@ -1 +1 @@
1
- {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/native/runtime.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAGV,mBAAmB,EACnB,oBAAoB,EACpB,mBAAmB,EAEpB,MAAM,eAAe,CAAC;AAkBvB,eAAO,MAAM,aAAa,GACxB,KAAK,QAAQ,EACb,QAAQ,mBAAmB,EAC3B,UAAS,oBAAyB,KACjC,OAAO,CAAC,mBAAmB,CA+e7B,CAAC"}
1
+ {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/native/runtime.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAGV,mBAAmB,EACnB,oBAAoB,EACpB,mBAAmB,EAEpB,MAAM,eAAe,CAAC;AAkBvB,eAAO,MAAM,aAAa,GACxB,KAAK,QAAQ,EACb,QAAQ,mBAAmB,EAC3B,UAAS,oBAAyB,KACjC,OAAO,CAAC,mBAAmB,CAof7B,CAAC"}
@@ -137,7 +137,9 @@ export const createRuntime = async (doc, plugin, options = {}) => {
137
137
  for (const surface of moving.keys())
138
138
  if (!surface.isConnected)
139
139
  moving.delete(surface);
140
- if (doc.hidden || overlayOpen() || (win.visualViewport && (win.visualViewport.scale !== 1 || win.visualViewport.offsetTop !== 0)))
140
+ if (doc.hidden || overlayOpen())
141
+ return [];
142
+ if (win.visualViewport && (win.visualViewport.scale !== 1 || win.visualViewport.offsetTop !== 0) && !search.hasActive())
141
143
  return [];
142
144
  return unprojected(sources.keys(), () => search
143
145
  .decorate(Array.from(doc.querySelectorAll(selector))
@@ -230,6 +232,7 @@ export const createRuntime = async (doc, plugin, options = {}) => {
230
232
  }
231
233
  if (result.rejectedSearches?.length) {
232
234
  search.reject(result.rejectedSearches);
235
+ lastSnapshot = '';
233
236
  dirty = true;
234
237
  }
235
238
  for (const candidate of candidates) {
@@ -480,12 +483,16 @@ export const createRuntime = async (doc, plugin, options = {}) => {
480
483
  async search(binding, active, focus) {
481
484
  if (!getNativeSearchBindings(doc).includes(binding) || !search.projected(binding))
482
485
  return false;
483
- if (active && !binding.active)
484
- search.begin(binding, revision + 1);
485
- binding.active = active;
486
- binding.focused = focus ?? (active && binding.focused);
487
- if (!active)
488
- search.retire(binding);
486
+ if (!active) {
487
+ search.setStatus(binding, false);
488
+ }
489
+ else {
490
+ const entering = !binding.active;
491
+ if (entering)
492
+ search.begin(binding, revision + 1);
493
+ const nextFocused = focus === true ? true : focus === false ? false : entering ? false : binding.focused;
494
+ search.setStatus(binding, true, nextFocused);
495
+ }
489
496
  await flush();
490
497
  return !stopped && search.projected(binding);
491
498
  },
package/docs/migration.md CHANGED
@@ -4,9 +4,111 @@ title: Migration
4
4
 
5
5
  # Migration
6
6
 
7
+ ## From the iOS 26 theme
8
+
9
+ For an app using `@rdlabo/ionic-theme-ios26`, the recommended migration keeps that package and adds `@rdlabo/ionic-theme-ios27`. The [README setup](https://docs.rdlabo.dev/projects/ionic-theme-ios27#get-started) selects iOS 27 or iOS 26 styles by browser capability and leaves Ionic's default iOS appearance on older browsers.
10
+
11
+ ### 1. Add the new package
12
+
13
+ Keep the iOS 26 package and add iOS 27. The new theme requires `@ionic/core` 8.8.1 or later (Ionic 8 or 9).
14
+
15
+ ```bash
16
+ npm install @rdlabo/ionic-theme-ios27
17
+ ```
18
+
19
+ ### 2. Make the styles adaptive
20
+
21
+ Replace unconditional iOS 26 imports in your global Sass stylesheet with two mutually exclusive branches. This example uses class-based dark mode:
22
+
23
+ ```diff
24
+ + @use 'sass:meta';
25
+ +
26
+ - @use '@rdlabo/ionic-theme-ios26/src/styles/default-variables.scss';
27
+ - @use '@rdlabo/ionic-theme-ios26/src/styles/ionic-theme-ios26.scss';
28
+ - @use '@rdlabo/ionic-theme-ios26/src/styles/ionic-theme-ios26-dark-class.scss';
29
+ - @use '@rdlabo/ionic-theme-ios26/src/styles/md-remove-ios-class-effect.scss';
30
+ + @supports (overflow-anchor: auto) {
31
+ + @include meta.load-css('@rdlabo/ionic-theme-ios27/src/styles/default-variables');
32
+ + @include meta.load-css('@rdlabo/ionic-theme-ios27/src/styles/ionic-theme-ios27');
33
+ + @include meta.load-css('@rdlabo/ionic-theme-ios27/src/styles/ionic-theme-ios27-dark-class');
34
+ + @include meta.load-css('@rdlabo/ionic-theme-ios27/src/styles/md-remove-ios-class-effect');
35
+ + }
36
+ +
37
+ + @supports (text-wrap: pretty) and (not (overflow-anchor: auto)) {
38
+ + @include meta.load-css('@rdlabo/ionic-theme-ios26/src/styles/default-variables');
39
+ + @include meta.load-css('@rdlabo/ionic-theme-ios26/src/styles/ionic-theme-ios26');
40
+ + @include meta.load-css('@rdlabo/ionic-theme-ios26/src/styles/ionic-theme-ios26-dark-class');
41
+ + @include meta.load-css('@rdlabo/ionic-theme-ios26/src/styles/md-remove-ios-class-effect');
42
+ + }
43
+ ```
44
+
45
+ Keep Ionic's matching dark palette. For system or always-dark mode, replace both `-dark-class` imports with the matching variant. If you use `md-ion-list-inset`, load the corresponding package's stylesheet inside each branch. Browsers without either feature retain Ionic's default styling.
46
+
47
+ ### 3. Switch the animations
48
+
49
+ Replace the iOS 26 animation import and gate the iOS 27 animations on the same browser features. Resolve the options before Ionic initializes:
50
+
51
+ ```diff
52
+ - import { iosTransitionAnimation, popoverEnterAnimation, popoverLeaveAnimation } from '@rdlabo/ionic-theme-ios26';
53
+ + import { iosTransitionAnimation, popoverEnterAnimation, popoverLeaveAnimation } from '@rdlabo/ionic-theme-ios27';
54
+ +
55
+ + function loadIOSAnimations() {
56
+ + if (typeof CSS === 'undefined') return {};
57
+ + if (!CSS.supports('overflow-anchor: auto') && !CSS.supports('text-wrap: pretty')) return {};
58
+ +
59
+ + return {
60
+ + navAnimation: iosTransitionAnimation,
61
+ + popoverEnter: popoverEnterAnimation,
62
+ + popoverLeave: popoverLeaveAnimation,
63
+ + };
64
+ + }
65
+
66
+ provideIonicAngular({
67
+ - navAnimation: isPlatform('ios') ? iosTransitionAnimation : undefined,
68
+ - popoverEnter: isPlatform('ios') ? popoverEnterAnimation : undefined,
69
+ - popoverLeave: isPlatform('ios') ? popoverLeaveAnimation : undefined,
70
+ + ...(isPlatform('ios') ? loadIOSAnimations() : {}),
71
+ });
72
+ ```
73
+
74
+ The iOS 27 page transition and popover animations serve both styled generations. Older browsers keep Ionic's defaults. The example uses Angular; pass the same options to React's `setupIonicReact` or Vue's `IonicVue`.
75
+
76
+ ### 4. Update customizations
77
+
78
+ Rename theme variables and opt-out classes used by your app. For example:
79
+
80
+ ```diff
81
+ ion-content {
82
+ - --ios26-content-box-shadow-rgb: 0, 0, 0;
83
+ + --ios-theme-content-box-shadow-rgb: 0, 0, 0;
84
+ }
85
+
86
+ - <ion-button class="ios26-disabled">Standard Ionic button</ion-button>
87
+ + <ion-button class="ios-theme-disabled">Standard Ionic button</ion-button>
88
+ ```
89
+
90
+ The old names remain as deprecated fallbacks. Check the resulting screens in light and dark modes on the browsers you support.
91
+
92
+ ### iOS 27 only
93
+
94
+ To switch entirely to iOS 27, remove the iOS 26 package and replace its stylesheet and animation imports. The stylesheet changes are:
95
+
96
+ ```diff
97
+ - @use '@rdlabo/ionic-theme-ios26/src/styles/default-variables.scss';
98
+ - @use '@rdlabo/ionic-theme-ios26/src/styles/ionic-theme-ios26.scss';
99
+ - @use '@rdlabo/ionic-theme-ios26/src/styles/ionic-theme-ios26-dark-class.scss';
100
+ - @use '@rdlabo/ionic-theme-ios26/src/styles/md-remove-ios-class-effect.scss';
101
+ + @use '@rdlabo/ionic-theme-ios27/src/styles/default-variables.scss';
102
+ + @use '@rdlabo/ionic-theme-ios27/src/styles/ionic-theme-ios27.scss';
103
+ + @use '@rdlabo/ionic-theme-ios27/src/styles/ionic-theme-ios27-dark-class.scss';
104
+ + @use '@rdlabo/ionic-theme-ios27/src/styles/md-remove-ios-class-effect.scss';
105
+ ```
106
+
107
+ Change the animation import from `@rdlabo/ionic-theme-ios26` to `@rdlabo/ionic-theme-ios27`; the existing `isPlatform('ios')` configuration can stay. See the README's [iOS 27-only setup](https://docs.rdlabo.dev/projects/ionic-theme-ios27#use-only-the-ios-27-theme). Unconditional imports apply the new styles on every browser using Ionic iOS mode.
108
+
7
109
  ## iOS 27 naming
8
110
 
9
- Use `@rdlabo/ionic-theme-ios27` for the current theme. Update stylesheet imports to `ionic-theme-ios27.scss` or `ionic-theme-ios27.css`, including the `-dark-always`, `-dark-system`, and `-dark-class` variants.
111
+ For the iOS 27 branch or an iOS 27-only app, use `@rdlabo/ionic-theme-ios27`. Its stylesheets are `ionic-theme-ios27.scss` or `ionic-theme-ios27.css`, including the `-dark-always`, `-dark-system`, and `-dark-class` variants. Keep the iOS 26 stylesheet names in the iOS 26 branch of an adaptive setup.
10
112
 
11
113
  Use the version-independent `--ios-theme-*` CSS variables. The corresponding `--ios26-*` variables remain supported as deprecated fallbacks. When both are set, the new name takes precedence. For example, use `--ios-theme-content-box-shadow-rgb` instead of `--ios26-content-box-shadow-rgb`.
12
114
 
@@ -22,7 +124,7 @@ Submit buttons now use each Ionic color's standard contrast value and an iOS 27
22
124
 
23
125
  ```diff
24
126
  :root {
25
- - --ion-color-primary-brightness-rgb: 130, 255, 255;
26
- - --ion-color-primary-brightness: #96feff;
127
+ - --ion-color-primary-brightness-rgb: 130, 255, 255;
128
+ - --ion-color-primary-brightness: #96feff;
27
129
  }
28
130
  ```
@@ -46,7 +46,7 @@ Native appearance follows the applied class, system or always-dark theme CSS. Sy
46
46
  | `ion-buttons` | Fixed toolbar, two or more direct clear `ion-button` / `ion-menu-button` children sharing the theme glass | One `UIGlassEffect` surface with independent native buttons |
47
47
  | `ion-back-button` | Standard icon and color in a fixed header/footer toolbar | Glass `UIButton`, using the resolved Ionic label/icon |
48
48
  | `ion-menu-button` | Fixed toolbar, inside the theme glass `ion-buttons` | Glass `UIButton`; original Ionic menu toggle |
49
- | `ion-tab-bar` | Fixed tabs, icon-only/label-only items, one icon per item, dot/text badges, selection and disabled state | `UITabBar` and `UITabBarItem` |
49
+ | `ion-tab-bar` | Fixed tabs, icon-only/label-only items, one icon per item, dot/text badges, selection and disabled state | `UITabBar` and `UITabBarItem` |
50
50
  | `ion-segment` | Fixed toolbar, non-scrollable, text **or** one icon per item | `UISegmentedControl` |
51
51
  | `ion-fab` / `ion-fab-button` / `ion-fab-list` | Glass FAB in an `ion-content` fixed slot; one main button and optional directional lists | Persistent glass `UIButton` per button; one FAB synchronization group |
52
52
 
@@ -92,7 +92,9 @@ Existing `attachTabBarSearchable(tabBar, fabButton, footer)` registrations autom
92
92
 
93
93
  Search registration does not bypass placement restrictions. Its searchbar and close button must be in fixed footer toolbars. Its trigger must belong to an `ion-fab[slot="fixed"]` directly inside `ion-content`, or directly on the existing non-scrolling `.ion-page` layout. A wrapper inside scrolling content is not a fixed slot.
94
94
 
95
- Opening search preserves the selected Ionic tab and does not automatically show the keyboard. Tap the search field or call the existing `ion-searchbar.setFocus()`. UIKit owns the search expansion, keyboard placement and return to the ordinary tabs. The trigger's resolved SVG and the search icon are projected from Ionic, including `ion-icon name`. The resting layout is measured against `ion-tab-bar` and both coordinates of the original FAB's center. A layout that cannot preserve those origins uses Web search.
95
+ While a registration is alive, Native UI Shell keeps the searchable controller even when the page is transitioning or temporarily unavailable (`available: false`), instead of demoting back to a separate ordinary control identity. Resting chrome (including Album before search opens) uses the same `UITabBar` + `ShellTabBar.fit` path as ordinary tabs, with the search trigger pinned to the FAB when available; `UISearchTab` is shown only while the search session is active. Flipping availability or entering/leaving search crossfades between those layers. Register before the destination page finishes entering (for example in `ionViewWillEnter`) so the first visit does not paint ordinary tabs and then swap.
96
+
97
+ Opening search preserves the selected Ionic tab and does not automatically show the keyboard (`automaticallyActivatesSearch` stays off). Tap the field or call `ion-searchbar.setFocus()` for the keyboard. While search is active, Native UI Shell freezes Web layout projection and holds Capacitor Keyboard resize at `none`; UIKit owns tab/search chrome (no Ionic `fit` mid-session). Leave re-fits ordinary tabs to `ion-tab-bar` and search to the FAB. Ordinary native tabs keep an optimistic selection until the Web `selected` state catches up. Input events and application `value` updates continue across the bridge until search closes. The trigger's resolved SVG and the search icon are projected from Ionic, including `ion-icon name`.
96
98
 
97
99
  Native edits pass through Ionic's input handlers, preserving `ionInput` debounce, `ionChange`, `ionFocus`, `ionBlur`, and `ionClear`. Programmatic `value` changes do not emit `ionInput`; synchronous application corrections and stale native input are distinguished. Native editing owns marked text and the caret. Returning through the footer's close action retains the value and does not emit `ionCancel` or `ionClear`.
98
100
 
@@ -245,7 +247,17 @@ On iOS, [`Components`](../ios/Sources/IonicNativeUIShellPlugin/Components) owns
245
247
 
246
248
  ## Demo and verification
247
249
 
248
- See the [demo and verification guide](../demo/native-ui-shell.md) for browser tests, Simulator tests and an independent SPM consumer built from the npm package.
250
+ The demo includes a `native-ui-shell` page for exercising fixed controls. From the repository root, build the library and run the browser tests:
251
+
252
+ ```sh
253
+ npm ci
254
+ npm run build
255
+ cd demo
256
+ npm ci
257
+ npx --no-install playwright install chromium
258
+ npx --no-install playwright test e2e/native-ui-shell.spec.ts e2e/native-ui-shell-edge.spec.ts
259
+ ```
249
260
 
261
+ For native interaction and placement tests, use Xcode 26 or later, XcodeGen and a booted iOS 26+ Simulator. From the repository root, run `sh scripts/verify-native-ui-shell.sh SIMULATOR_UDID`. This also builds an independent Swift Package Manager consumer from the npm package. Run `sh scripts/verify-native-search.sh SIMULATOR_UDID` for searchable-tab integration, or add `edge` to that command for placement, navigation and keyboard edge cases. The scripts print the location of their local test artifacts.
250
262
 
251
263
  Search controllers retain their UIKit-managed transition and are excluded from the ordinary control acquisition crossfade.
@@ -92,6 +92,15 @@ This is an introductory group. Place regular list items in a separate `ion-item-
92
92
 
93
93
  ## Full-width segments
94
94
 
95
+ Add `.segment-style-glass` to give a segment the same glass surface and selected indicator treatment as the tab bar. The class preserves the segment's existing dimensions and text colors, supports scrollable segments, and respects Ionic's public `--background` property.
96
+
97
+ ```html
98
+ <ion-segment class="segment-style-glass" value="available">
99
+ <ion-segment-button value="available">Available</ion-segment-button>
100
+ <ion-segment-button value="away">Away</ion-segment-button>
101
+ </ion-segment>
102
+ ```
103
+
95
104
  For colored segments, use Ionic's `color` property (for example, `color="primary"` or `color="secondary"`). The selected surface uses the palette's base color and the selected label uses its contrast color. The optional moving glass inherits the same surface color; custom Ionic palettes work without additional registration.
96
105
 
97
106
  Add `.segment-expand` when segment buttons should divide the available width evenly. The class also changes the Liquid Glass effect sizing when `registerSegmentEffect` is used.