From dcdfe79865c444d0843464f0e6ad6bb5ffc09f19 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Tue, 1 Sep 2026 16:55:11 +0400 Subject: [PATCH 01/53] Expose the disabled state on the widget root and stop a11y checks passing vacuously aria-disabled was set on _getAriaTarget(), which in a composite widget is a descendant: the input of a text editor, the select-file button of FileUploader, the field of Lookup. Everything outside that element - tags, labels, file lists - was dimmed but carried no disabled semantics, so assistive technology and axe judged it as ordinary text. 12 of 24 dimmed widget roots had no marker axe accepts. The root is now marked as well; the previous target keeps its attribute, so the change is additive. Disabled command links in the grid and disabled navigation buttons in Pagination had a disabled look and no marker at all, and are marked too. With that in place the color-contrast suppressions for disabled TagBox, FileUploader and DateRangeBox are unnecessary: all 44 disabled configurations of their option matrices report zero violations in both light and dark, and 15 violations without the markup fix. The suppression list and two of the demo entries are removed; the cardView ones are kept, with the comment corrected - the sortable source is active content painted with the disabled roles, and every theme is below 4.5:1 there, so the fix belongs to the shared base layer. Two harness defects found on the way: - a11yCheck() returned without a single assertion when the caller excluded color-contrast, so the test reported as passed. Applicability is now a predicate, testAccessibility declares such tests with test.skip, and a direct call that would check nothing throws. - runOnly: '' in the DataGrid accessibility tests is normalised by axe into { type: 'tag', values: [''] }, which matches none of the 104 rules. Seven of the eight call sites ran no rule at all and the eighth ran one, in every theme. --- apps/demos/testing/common.test.ts | 5 ++-- .../helpers/accessibility/test.ts | 29 +++++-------------- .../helpers/accessibility/utils.ts | 14 +++++++-- .../accessibility/cardView/columnSortable.ts | 5 +++- .../tests/accessibility/cardView/sortable.ts | 6 +++- .../tests/accessibility/dataGrid/common.ts | 24 +++------------ .../js/__internal/core/widget/widget.ts | 16 ++++++++-- .../grids/grid_core/editing/m_editing.ts | 1 + .../pagination/common/light_button.tsx | 3 ++ .../pagination/pages/page_index_selector.tsx | 6 +++- 10 files changed, 58 insertions(+), 51 deletions(-) diff --git a/apps/demos/testing/common.test.ts b/apps/demos/testing/common.test.ts index cab07e79bd39..7748d58875b3 100644 --- a/apps/demos/testing/common.test.ts +++ b/apps/demos/testing/common.test.ts @@ -62,9 +62,8 @@ const getIgnoredRules = (testName) => { if ((isMaterial() || isFluent()) && [ - // False positive: contrast rules do not apply to disabled tags - 'Accordion-Overview', - 'TagBox-Overview', + // Cause not reproduced: the demo has no disabled element, so the original + // "disabled tags" reason does not apply to it. Needs a measurement in CI. 'TreeList-StatePersistence', // False positive: contrast rules do not apply to custom orange color 'CardView-FieldTemplate', diff --git a/e2e/testcafe-devextreme/helpers/accessibility/test.ts b/e2e/testcafe-devextreme/helpers/accessibility/test.ts index 42aa9d0ab8c4..58a75a6f85fd 100644 --- a/e2e/testcafe-devextreme/helpers/accessibility/test.ts +++ b/e2e/testcafe-devextreme/helpers/accessibility/test.ts @@ -2,7 +2,7 @@ import { ElementContext } from 'axe-core'; import type { WidgetName } from 'devextreme-testcafe-models/types'; import { createWidget } from '../createWidget'; import { getThemeName } from '../themeUtils'; -import { a11yCheck, A11yCheckOptions } from './utils'; +import { a11yCheck, isA11yCheckApplicable, A11yCheckOptions } from './utils'; import { generateOptionMatrix, Options } from '../generateOptionMatrix'; export interface Configuration { @@ -32,8 +32,6 @@ const getOptionConfigurations = ( return generateOptionMatrix(options); }; -const componentsWithDisabledColorContrastIssues: WidgetName[] = ['dxTagBox', 'dxFileUploader', 'dxDateRangeBox']; - export const testAccessibility = ( configuration: Configuration, ): void => { @@ -47,25 +45,14 @@ export const testAccessibility = ( const optionConfigurations = getOptionConfigurations(options); - optionConfigurations.forEach((optionConfiguration, index) => { - test(`${component}: test with axe #${index}`, async (t) => { - const currentA11yCheckConfig = { ...a11yCheckConfig } as A11yCheckOptions; - const isComponentDisabled = (optionConfiguration as Record).disabled; - const shouldIgnoreColorContrast = componentsWithDisabledColorContrastIssues - .includes(component) && isComponentDisabled; - - if (shouldIgnoreColorContrast) { - if (currentA11yCheckConfig.runOnly === 'color-contrast') { - return; - } + // A theme that runs color-contrast only has nothing left to check once the rule is off. + // Declaring the skip here keeps it visible in the report; bailing out of the test body + // used to report it as passed. + const declareTest = isA11yCheckApplicable(a11yCheckConfig) ? test : test.skip; - currentA11yCheckConfig.rules = { - ...currentA11yCheckConfig.rules, - 'color-contrast': { enabled: false }, - }; - } - - await a11yCheck(t, currentA11yCheckConfig, selector, optionConfiguration); + optionConfigurations.forEach((optionConfiguration, index) => { + declareTest(`${component}: test with axe #${index}`, async (t) => { + await a11yCheck(t, a11yCheckConfig, selector, optionConfiguration); }).before(async (t) => { await createWidget( component, diff --git a/e2e/testcafe-devextreme/helpers/accessibility/utils.ts b/e2e/testcafe-devextreme/helpers/accessibility/utils.ts index db25533ac5e0..e570b7e77209 100644 --- a/e2e/testcafe-devextreme/helpers/accessibility/utils.ts +++ b/e2e/testcafe-devextreme/helpers/accessibility/utils.ts @@ -20,6 +20,11 @@ const isColorContrastChecked = (options: A11yCheckOptions): boolean => { return options.runOnly === undefined || options.runOnly === COLOR_CONTRAST_RULE; }; +// Whether the given configuration leaves anything for the current theme to check. Call sites +// use it to declare a test with `test.skip`, so a check that cannot run is visible as skipped +// instead of counted as passed. +export const isA11yCheckApplicable = (options: A11yCheckOptions = defaultOptions): boolean => getThemeName() !== 'fluent-next' || isColorContrastChecked(options); + const createFullReport = (results, configuration) => { let report = createReport(results.violations); @@ -41,9 +46,14 @@ Promise => { // so only color-contrast is re-checked for it — regardless of the caller's config. const isColorContrastOnly = getThemeName() === 'fluent-next'; - // Nothing is left to check: the caller excluded the only rule this theme runs. + // Returning here used to report the test as passed with no assertion at all. A check that + // cannot run has to be declared as skipped where the test is declared, not swallowed here. if (isColorContrastOnly && !isColorContrastChecked(options)) { - return; + throw new Error( + 'a11yCheck was called on fluent-next with a configuration that excludes color-contrast, ' + + 'the only rule this theme runs. Nothing would be checked. Either declare the test with ' + + 'isA11yCheckApplicable() so it is skipped explicitly, or leave color-contrast enabled.', + ); } const effectiveOptions: A11yCheckOptions = isColorContrastOnly diff --git a/e2e/testcafe-devextreme/tests/accessibility/cardView/columnSortable.ts b/e2e/testcafe-devextreme/tests/accessibility/cardView/columnSortable.ts index e5cbb0ab1af5..698e166e24c0 100644 --- a/e2e/testcafe-devextreme/tests/accessibility/cardView/columnSortable.ts +++ b/e2e/testcafe-devextreme/tests/accessibility/cardView/columnSortable.ts @@ -16,7 +16,10 @@ test('headerPanel dragging column when it has sorting and headerFilter', async ( await triggerDragStart(columnElement); const a11yCheckConfig = { - // False positive: contrast rules do not apply to disabled elements + // Not a false positive: the sortable source is ordinary, active content painted with + // the disabled colour roles in base/cardView/header_panel/item/_index.scss. Every theme + // is below 4.5:1 (1.61 to 2.61), so the fix belongs to the shared base layer. + // See fluent-next/DISABLED_STATES.md. rules: { 'color-contrast': { enabled: false } }, }; await a11yCheck(t, a11yCheckConfig, CARD_VIEW_SELECTOR); diff --git a/e2e/testcafe-devextreme/tests/accessibility/cardView/sortable.ts b/e2e/testcafe-devextreme/tests/accessibility/cardView/sortable.ts index 1895e778ba65..df6a35979195 100644 --- a/e2e/testcafe-devextreme/tests/accessibility/cardView/sortable.ts +++ b/e2e/testcafe-devextreme/tests/accessibility/cardView/sortable.ts @@ -16,7 +16,11 @@ const DRAG_MOVE_Y_COEFFICIENT = 1; const a11yCheckConfig = { rules: { - // False positive: contrast rules do not apply to disabled elements + // Not a false positive: the sortable source is ordinary, active content, but + // base/cardView/header_panel/item/_index.scss paints it with the disabled colour roles. + // Measured on the built bundles - generic 1.61, fluent 1.65, fluent-next 2.11, + // material 2.61 - so every theme is below 4.5:1 and the fix belongs to the shared + // base layer, not to one theme. See fluent-next/DISABLED_STATES.md. 'color-contrast': { enabled: false }, // NOTE: Draggable template is outside the role="main" landmark region: { enabled: false }, diff --git a/e2e/testcafe-devextreme/tests/accessibility/dataGrid/common.ts b/e2e/testcafe-devextreme/tests/accessibility/dataGrid/common.ts index e8ec51f15839..040506d520d0 100644 --- a/e2e/testcafe-devextreme/tests/accessibility/dataGrid/common.ts +++ b/e2e/testcafe-devextreme/tests/accessibility/dataGrid/common.ts @@ -196,7 +196,6 @@ test('Filter row - filter menu', async (t) => { await a11yCheck(t, { ...a11yCheckConfig, - runOnly: '', rules: { 'aria-command-name': { enabled: true }, }, @@ -298,10 +297,7 @@ test('Filter panel - popup with filter builder', async (t) => { .expect(filterPanel.isOpened) .ok(); - await a11yCheck(t, { - ...a11yCheckConfig, - runOnly: '', - }); + await a11yCheck(t, a11yCheckConfig); }).before(async () => createWidget('dxDataGrid', { dataSource: getData(10, 5), keyExpr: 'field_0', @@ -347,10 +343,7 @@ test('Search panel - highlight', async (t) => { .expect(dataGrid.isReady()) .ok(); - await a11yCheck(t, { - ...a11yCheckConfig, - runOnly: '', - }, DATA_GRID_SELECTOR); + await a11yCheck(t, a11yCheckConfig, DATA_GRID_SELECTOR); }).before(async () => createWidget('dxDataGrid', { dataSource: getData(10, 5), keyExpr: 'field_0', @@ -517,7 +510,6 @@ test('Column chooser with the \'select\' mode', async (t) => { await a11yCheck(t, { ...a11yCheckConfig, - runOnly: '', rules: { 'scrollable-region-focusable': { enabled: false }, }, @@ -563,7 +555,6 @@ test('Empty column chooser', async (t) => { await a11yCheck(t, { ...a11yCheckConfig, - runOnly: '', rules: { 'aria-required-children': { enabled: false }, }, @@ -657,10 +648,7 @@ test('Row editing mode - confirm delete message', async (t) => { .expect(isDialogOpened) .ok(); - await a11yCheck(t, { - ...a11yCheckConfig, - runOnly: '', - }); + await a11yCheck(t, a11yCheckConfig); }).before(async () => createWidget('dxDataGrid', { dataSource: getData(10, 5), keyExpr: 'field_0', @@ -967,10 +955,7 @@ test('Export', async (t) => { .expect(exportButton.isOpened) .ok(); - await a11yCheck(t, { - ...a11yCheckConfig, - runOnly: '', - }); + await a11yCheck(t, a11yCheckConfig); }).before(async () => createWidget('dxDataGrid', { dataSource: getData(10, 5), keyExpr: 'field_0', @@ -998,7 +983,6 @@ test('Context menu', async (t) => { await a11yCheck(t, { ...a11yCheckConfig, - runOnly: '', rules: { region: { enabled: false }, }, diff --git a/packages/devextreme/js/__internal/core/widget/widget.ts b/packages/devextreme/js/__internal/core/widget/widget.ts index 5d19d3d760c0..c7c7a968c5e2 100644 --- a/packages/devextreme/js/__internal/core/widget/widget.ts +++ b/packages/devextreme/js/__internal/core/widget/widget.ts @@ -562,9 +562,21 @@ class Widget< } _toggleDisabledState(value: boolean | undefined): void { - this.$element().toggleClass(DISABLED_STATE_CLASS, Boolean(value)); + const $element = this.$element(); + + $element.toggleClass(DISABLED_STATE_CLASS, Boolean(value)); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - this.setAria('disabled', value || undefined); + const state = value || undefined; + + this.setAria('disabled', state); + + // The aria target is the focus target, and in a composite widget that is a descendant: + // the input of a text editor, the item container of a list. Assistive technology and + // axe resolve the state from the element or an ancestor, so the parts that live outside + // the focus target - tags, labels, file lists - need the root marked as well. + if (this._getAriaTarget().get(0) !== $element.get(0)) { + this.setAria('disabled', state, $element); + } } _toggleIndependentState(): void { diff --git a/packages/devextreme/js/__internal/grids/grid_core/editing/m_editing.ts b/packages/devextreme/js/__internal/grids/grid_core/editing/m_editing.ts index 06c4dfee73d7..6f06ae89903a 100644 --- a/packages/devextreme/js/__internal/grids/grid_core/editing/m_editing.ts +++ b/packages/devextreme/js/__internal/grids/grid_core/editing/m_editing.ts @@ -2356,6 +2356,7 @@ class EditingControllerImpl extends modules.ViewController { if (this._isButtonDisabled(button, options)) { $button.addClass('dx-state-disabled'); + this.setAria('disabled', 'true', $button); } else if (!button.template || button.onClick) { eventsEngine.on($button, addNamespace('click', EDITING_NAMESPACE), this.createAction((e) => { button.onClick?.call(button, extend({}, e, { row: options.row, column: options.column })); diff --git a/packages/devextreme/js/__internal/pagination/common/light_button.tsx b/packages/devextreme/js/__internal/pagination/common/light_button.tsx index 8c2b808214e1..674ca1b739a6 100644 --- a/packages/devextreme/js/__internal/pagination/common/light_button.tsx +++ b/packages/devextreme/js/__internal/pagination/common/light_button.tsx @@ -15,6 +15,7 @@ export interface LightButtonProps { label?: string; tabIndex?: number; selected?: boolean; + disabled?: boolean; onClick?: EventCallback; } @@ -23,6 +24,7 @@ export const LightButtonDefaultProps: LightButtonProps = { label: '', tabIndex: 0, selected: false, + disabled: false, }; export class LightButton extends InfernoComponent { @@ -86,6 +88,7 @@ export class LightButton extends InfernoComponent { role="button" aria-label={this.props.label} aria-current={this.props.selected ? 'page' : undefined} + aria-disabled={this.props.disabled ? 'true' : undefined} > {this.props.children} diff --git a/packages/devextreme/js/__internal/pagination/pages/page_index_selector.tsx b/packages/devextreme/js/__internal/pagination/pages/page_index_selector.tsx index 4f1e6595223c..0b59dbda9be9 100644 --- a/packages/devextreme/js/__internal/pagination/pages/page_index_selector.tsx +++ b/packages/devextreme/js/__internal/pagination/pages/page_index_selector.tsx @@ -56,7 +56,7 @@ const PageIndexSelectorDefaultProps: PageIndexSelectorPropsType = { itemCount: PaginationDefaultProps.itemCount, }; -interface NavigationButtonProps extends Pick { navigate: LightButtonProps['onClick'] } +interface NavigationButtonProps extends Pick { navigate: LightButtonProps['onClick'] } interface NavigationButtonPropsCache { prevButtonProps: NavigationButtonProps | undefined; @@ -104,6 +104,7 @@ export class PageIndexSelector extends BaseInfernoComponent this.navigateToPage(rtlAwareDirection), }; } @@ -182,6 +183,7 @@ export class PageIndexSelector extends BaseInfernoComponent )} @@ -224,6 +227,7 @@ export class PageIndexSelector extends BaseInfernoComponent )} From c12b3a8c713532180821ad7182922df4e6609995 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Tue, 1 Sep 2026 16:56:17 +0400 Subject: [PATCH 02/53] fluent-next: paint disabled states from the disabled roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The theme carried the disabled state of most components with one blanket rule, .dx-state-disabled .dx-widget { opacity: … }, and only about twenty components opted out of it and painted themselves. Measured over the whole disable-able surface, 18 components had no disabled appearance of their own, and Toolbar had none at all: base/toolbar opts it out of the dim and puts nothing in its place, while the "do not dim nested widgets" rule clears the dim from its item widgets too, so a disabled toolbar rendered exactly like an enabled one in every theme. Each migrated component now opts out of the dim and paints its own parts from the disabled roles: toolbar, menu, tabs, treeView, stepper, gallery, filterBuilder, pagination, cardView, tileView, fileUploader, scheduler, pivotGrid, and the grid family through the grid-base mixin. Where the component already had tokens for its individually disabled items, those are reused; the rest get one new tier name each. cardView deliberately does not reuse its header-panel-item-*-disabled names: base applies those to the sortable drag source, not to a disabled state. Five components keep the dim, and that is the right mechanism for them: three layout containers with no surface of their own (splitter, drawer, box), the colour palette of ColorView, and Chat with its own hardcoded opacity. So the blanket rule is narrowed by attrition rather than removed. The Scheduler demo read --dxds-color-content-subtle-disabled, a role dropped in 262.15.0; it moves to the surviving one. The gate caught it only after a real install, because the working tree still had 262.10.1 linked. Verified with playground/disabled-readonly-compare.html, which puts enabled, disabled, disabled-without-the-dim and legacy fluent side by side and compares the sorted colour multiset of each component's own elements and pseudo-elements: 37 components paint their own state, 7 are covered through their children, and none renders a disabled state indistinguishable from enabled. Rationale and the full inventory are in fluent-next/DISABLED_STATES.md. --- .../widgets/fluent-next/DISABLED_STATES.md | 251 +++++++++++++++++ .../widgets/fluent-next/cardView/_colors.scss | 2 + .../widgets/fluent-next/cardView/_index.scss | 16 ++ .../widgets/fluent-next/cardView/_public.scss | 1 + .../fluent-next/fileUploader/_colors.scss | 2 + .../fluent-next/fileUploader/_index.scss | 17 ++ .../fluent-next/fileUploader/_public.scss | 1 + .../fluent-next/filterBuilder/_colors.scss | 2 + .../fluent-next/filterBuilder/_index.scss | 15 + .../fluent-next/filterBuilder/_public.scss | 1 + .../widgets/fluent-next/gallery/_colors.scss | 2 + .../widgets/fluent-next/gallery/_index.scss | 13 + .../widgets/fluent-next/gallery/_public.scss | 1 + .../widgets/fluent-next/gridBase/_colors.scss | 1 + .../widgets/fluent-next/gridBase/_index.scss | 22 ++ .../widgets/fluent-next/gridBase/_public.scss | 1 + .../scss/widgets/fluent-next/menu/_index.scss | 13 + .../fluent-next/pagination/_colors.scss | 2 + .../fluent-next/pagination/_index.scss | 14 + .../fluent-next/pagination/_public.scss | 1 + .../fluent-next/pivotGrid/_colors.scss | 2 + .../widgets/fluent-next/pivotGrid/_index.scss | 17 ++ .../fluent-next/pivotGrid/_public.scss | 1 + .../fluent-next/scheduler/_colors.scss | 2 + .../widgets/fluent-next/scheduler/_index.scss | 25 ++ .../fluent-next/scheduler/_public.scss | 1 + .../widgets/fluent-next/stepper/_index.scss | 19 ++ .../scss/widgets/fluent-next/tabs/_index.scss | 17 ++ .../widgets/fluent-next/tileView/_colors.scss | 2 + .../widgets/fluent-next/tileView/_index.scss | 11 + .../widgets/fluent-next/tileView/_public.scss | 1 + .../widgets/fluent-next/toolbar/_colors.scss | 2 + .../widgets/fluent-next/toolbar/_index.scss | 19 ++ .../widgets/fluent-next/toolbar/_public.scss | 1 + .../widgets/fluent-next/treeView/_index.scss | 13 + .../disabled-readonly-compare-frame.html | 229 ++++++++++++++++ .../playground/disabled-readonly-compare.html | 193 +++++++++++++ .../playground/disabled-states-audit.html | 258 ++++++++++++++++++ 38 files changed, 1191 insertions(+) create mode 100644 packages/devextreme-scss/scss/widgets/fluent-next/DISABLED_STATES.md create mode 100644 packages/devextreme/playground/disabled-readonly-compare-frame.html create mode 100644 packages/devextreme/playground/disabled-readonly-compare.html create mode 100644 packages/devextreme/playground/disabled-states-audit.html diff --git a/packages/devextreme-scss/scss/widgets/fluent-next/DISABLED_STATES.md b/packages/devextreme-scss/scss/widgets/fluent-next/DISABLED_STATES.md new file mode 100644 index 000000000000..414bd5b850dd --- /dev/null +++ b/packages/devextreme-scss/scss/widgets/fluent-next/DISABLED_STATES.md @@ -0,0 +1,251 @@ +# fluent-next: disabled-состояния — замер и решения + +Закрывает пункт 3 «Ближайших задач» в [HANDOFF.md](HANDOFF.md) («дизайн-финал: disabled-состояния +через disabled-роли») и вопрос «скипнутые тесты в axe color-contrast сделаны неверно». + +Инструмент замера — `packages/devextreme/playground/disabled-states-audit.html`: галерея всех +disabled-состояний темы + прогон axe с `runOnly: 'color-contrast'`. Результат в +`window.__disabledAudit`. Поднимается статик-сервером от корня репозитория, требует +`pnpm nx build:ci devextreme-scss` и `pnpm nx build:dev devextreme`. + +## Главный вывод + +**Контраст падал не из-за цветов, а из-за разметки.** Токены disabled по замыслу не проходят +AA — так же устроен Fluent 2 (`colorNeutralForegroundDisabled` ≈ 2:1), и WCAG 1.4.3 выводит +неактивные компоненты из-под требования контраста. Перекраска на роли не сняла бы ни одного +подавления. + +| роль | light | dark | контраст на своей поверхности | +|---|---|---|---| +| `--dxds-color-content-disabled` | `#ababab` | `#767676` | 2.11 (light) / 3.98 (dark) | +| `--dxds-color-bg-disabled` | `#f5f5f5` | `#161616` | | +| `--dxds-color-border-disabled` | `#d7d7d7` | `#4c4c4c` | | +| глобальная `opacity: .35` над канвой | `#adadad` | `#717171` | 2.24 / 3.18 | + +То есть оба подхода — дим и роли — дают одно и то же число в пределах 0.2. Разница между ними +не в контрасте, а в управляемости: дим гасит всё поддерево целиком и не переопределяется +по частям, роли переопределяются через тир `--dx-*`. + +Освобождение от требования контраста axe выдаёт **только** элементу, у которого он сам или +любой предок помечен как disabled (`disabled` на fieldset/button/select/input/textarea либо +`aria-disabled="true"` на чём угодно — `axe-core/axe.js`, `isDisabled`). Всё остальное судится +как обычный текст. + +## Что было сломано: `aria-disabled` не на корне виджета + +`Widget._toggleDisabledState` ставил атрибут на `_getAriaTarget()` → `_focusTarget()`, а у +композитных виджетов это потомок. Замер до правки: **12 из 24** приглушённых корней виджетов не +несли маркера, который axe принимает. + +| виджет | куда уезжал `aria-disabled` | что оставалось снаружи | +|---|---|---| +| TagBox, TextBox, SelectBox, NumberBox и прочие text-editor'ы | `` | теги, лейбл, плейсхолдер, кнопки | +| DateRangeBox | два `` | лейблы, разделитель, кнопка календаря | +| FileUploader | `.dx-fileuploader-button` — **и терялся вовсе**, кнопки ещё нет на момент вызова | список файлов, подпись «or drop file here» | +| Lookup | `.dx-lookup-field` | остальное шасси | +| List, MenuBase, TreeView-search | item container | поиск, «no data», группы | +| Calendar | `_$viewsWrapper` | навигатор | +| Form | первый таб-стоп поля | подписи, шапка | +| Chat | внутренний `