diff --git a/.github/skills/syncfusion-blazor-toolkit-buttons/SKILL.md b/.github/skills/syncfusion-blazor-toolkit-buttons/SKILL.md index a20cc71..b334ccc 100644 --- a/.github/skills/syncfusion-blazor-toolkit-buttons/SKILL.md +++ b/.github/skills/syncfusion-blazor-toolkit-buttons/SKILL.md @@ -11,7 +11,7 @@ description: > navigation menu UI (use SfMenu / SfToolbar — not in this skill), icon-only controls that should suppress focus (use a styled syncfusion-blazor-toolkit-notifications spinner instead). -compatibility: .NET 8+, render-modes: Static SSR, Server, WebAssembly, Auto +compatibility: ".NET 8+, render-modes: Static SSR, Server, WebAssembly, Auto" metadata: author: "Syncfusion Inc" version: "1.0.0" diff --git a/.github/skills/syncfusion-blazor-toolkit-calendars/SKILL.md b/.github/skills/syncfusion-blazor-toolkit-calendars/SKILL.md index 8ab9ec4..39acc46 100644 --- a/.github/skills/syncfusion-blazor-toolkit-calendars/SKILL.md +++ b/.github/skills/syncfusion-blazor-toolkit-calendars/SKILL.md @@ -12,7 +12,7 @@ description: > syncfusion-blazor-toolkit-inputs), scheduling or calendar recurrence (use Scheduler — not in this skill), or date validation that requires an API round-trip (handle in EditContext validator instead). -compatibility: .NET 8+, render-modes: Server, WebAssembly, Auto +compatibility: ".NET 8+, render-modes: Server, WebAssembly, Auto" metadata: author: "Syncfusion Inc" version: "1.0.0" diff --git a/.github/skills/syncfusion-blazor-toolkit-inputs/SKILL.md b/.github/skills/syncfusion-blazor-toolkit-inputs/SKILL.md index 0b4035f..b7e4a17 100644 --- a/.github/skills/syncfusion-blazor-toolkit-inputs/SKILL.md +++ b/.github/skills/syncfusion-blazor-toolkit-inputs/SKILL.md @@ -14,7 +14,7 @@ description: > DO NOT USE FOR: button controls (use syncfusion-blazor-toolkit-buttons), rich text editing (use SfRichTextEditor — not in this skill), date or time inputs (use syncfusion-blazor-toolkit-calendars). -compatibility: .NET 8+, render-modes: Static SSR (text/checkbox/numeric/Switch); Server, WebAssembly, Auto (all) +compatibility: ".NET 8+, render-modes: Static SSR (text/checkbox/numeric/Switch); Server, WebAssembly, Auto (all)" metadata: author: "Syncfusion Inc" version: "1.0.0" diff --git a/.github/skills/syncfusion-blazor-toolkit-notifications/SKILL.md b/.github/skills/syncfusion-blazor-toolkit-notifications/SKILL.md index 7a9e49e..c3a6432 100644 --- a/.github/skills/syncfusion-blazor-toolkit-notifications/SKILL.md +++ b/.github/skills/syncfusion-blazor-toolkit-notifications/SKILL.md @@ -15,7 +15,7 @@ description: > this skill), action-bearing toast notifications (use SfToast — not in this skill), or progress bars with explicit percent (use SfProgressBar — not in this skill). -compatibility: .NET 8+, render-modes: Server, WebAssembly, Auto +compatibility: ".NET 8+, render-modes: Server, WebAssembly, Auto" metadata: author: "Syncfusion Inc" version: "1.0.0" diff --git a/gulpfile.js b/gulpfile.js index 52f6bf8..e6ca7e9 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -3,7 +3,6 @@ var shelljs = global.shelljs = global.shelljs || require('shelljs'); var gulp = global.gulp = global.gulp || require('gulp'); const glob = require('glob'); const sass = require('gulp-sass')(require('sass')); -const cleanCSS = require('gulp-clean-css'); const rename = require('gulp-rename'); var componentThemeOrder = [ @@ -64,6 +63,7 @@ function removeCustomUse(fileContent) { gulp.task('combined-scss', function (done) { // Get the all components scss files' path var componentFiles = glob.sync(`./src/wwwroot/styles/*.scss`); + shelljs.mkdir('-p', './src/wwwroot/styles/combined-scss/'); var getFluentScss = ''; // Place component styles as per styles order for (var themeOrder of componentThemeOrder) { @@ -75,8 +75,38 @@ gulp.task('combined-scss', function (done) { } } getFluentScss = removeCustomUse(getFluentScss); - shelljs.mkdir('-p', './src/wwwroot/styles/combined-scss/'); fs.writeFileSync('./src/wwwroot/styles/combined-scss/fluent.scss', reorderUseRules(getFluentScss), 'utf8'); + var hcBody = ''; + for (var hcOrder of componentThemeOrder) { + var hcPaths = componentFiles.filter((value) => { return value.indexOf('styles/' + hcOrder) !== -1; }); + if (!hcPaths.length) continue; + var content = stripBom(fs.readFileSync(hcPaths[0], 'utf8')); + if (hcOrder === 'base') content = stripRootScopes(content); + hcBody += '\n' + content; + } + hcBody = removeCustomUse(hcBody); + hcBody = reorderUseRules(hcBody); + + var tokensSrc = stripBom(fs.readFileSync('./src/wwwroot/styles/highcontrast-tokens.scss', 'utf8')); + var unlayeredMarker = '// Unscoped component overrides (component-state corrections that var() tokens'; + var markerIdx = tokensSrc.indexOf(unlayeredMarker); + var unlayeredPostlude = ''; + if (markerIdx >= 0) { + var forcedIdx = tokensSrc.indexOf('@media (forced-colors: active)', markerIdx); + var endIdx = forcedIdx > markerIdx ? forcedIdx : tokensSrc.length; + unlayeredPostlude = tokensSrc.substring(markerIdx, endIdx).trim() + '\n'; + } + + fs.writeFileSync( + './src/wwwroot/styles/combined-scss/highcontrast.scss', + "@use 'highcontrast-tokens';\n" + hcBody + '\n' + unlayeredPostlude, + 'utf8' + ); + + fs.copyFileSync( + './src/wwwroot/styles/highcontrast-tokens.scss', + './src/wwwroot/styles/combined-scss/_highcontrast-tokens.scss' + ); done(); }); @@ -84,26 +114,81 @@ function stripBom(content) { return content.replace(/^\uFEFF/, ''); } +function stripRootScopes(content) { + var out = '', i = 0; + while (i < content.length) { + // Skip a preceding @layer { ... } block if it's a theme + // layer or contains a :root{} rule. + var layerSearch = content.slice(i).search(/(^|[;}]\s*)@layer\s+[a-zA-Z][\w.]*\s*\{/); + if (layerSearch >= 0) { + var headerMatch = content.slice(i + layerSearch).match(/@layer\s+([a-zA-Z][\w.]*)\s*\{/); + if (headerMatch) { + var openBrace = i + layerSearch + headerMatch.index + headerMatch[0].length - 1; + var end = openBrace, depth = 1; + while (++end < content.length && depth > 0) { + if (content[end] === '{') depth++; + else if (content[end] === '}') depth--; + } + var body = content.slice(i + layerSearch, end); + if (/fluent|themes/.test(headerMatch[1]) || /:root\s*\{/.test(body)) { + out += content.slice(i, i + layerSearch); + i = end; + continue; + } + out += content.slice(i, end); + i = end; + continue; + } + } + // Process the next rule + var brace = content.indexOf('{', i); + if (brace === -1) { out += content.slice(i); break; } + var selStart = Math.max(content.lastIndexOf(';', brace), content.lastIndexOf('}', brace), 0) + 1; + while (selStart < brace && /\s/.test(content[selStart])) selStart++; + var selector = content.slice(selStart, brace).trim(); + var depth = 1, j = brace + 1; + while (j < content.length && depth > 0) { + if (content[j] === '{') depth++; + else if (content[j] === '}') depth--; + if (depth === 0) break; + j++; + } + if (/^:root\b/.test(selector) || /\.e-dark-mode\b/.test(selector)) { + out += content.slice(i, selStart); + } else { + out += content.slice(i, j + 1); + } + i = j + 1; + } + return out; +} + // Compile SCSS to CSS. gulp.task('scss-to-css', function (done) { + function cleanup() { + try { fs.unlinkSync('./src/wwwroot/styles/combined-scss/_highcontrast-tokens.scss'); } catch (e) { } + console.log("SCSS to CSS compiled successfully"); + done(); + } return gulp.src( ['./src/wwwroot/styles/combined-scss/*.scss', './src/wwwroot/styles/*.scss'], - { ignore: ['./src/wwwroot/styles/icons.scss', './src/wwwroot/styles/animation.scss', './src/wwwroot/styles/base.scss'] } - ) // Select all SCSS files in the directory for compiling to css except base and icons scss - .pipe(sass().on('error', function (error) { - // Handle SCSS compilation errors - fs.appendFileSync('./gulp_error.log', 'Failed scss-to-css task \nDetails:\n' + error.message + '\n'); - console.error('Sass Compilation Error:', error.messageFormatted); - process.exit(1); - })) - // Minify and write only the .min.css files - .pipe(cleanCSS()) - .pipe(rename({ suffix: '.min' })) - .pipe(gulp.dest('./src/wwwroot/styles')) - .on('end', function () { - console.log("SCSS to CSS compiled successfully"); - done(); - }); + { ignore: [ + './src/wwwroot/styles/icons.scss', + './src/wwwroot/styles/animation.scss', + './src/wwwroot/styles/base.scss', + './src/wwwroot/styles/highcontrast-tokens.scss', + './src/wwwroot/styles/combined-scss/_highcontrast-tokens.scss' + ] } + ) + .pipe(sass({ outputStyle: 'compressed' }).on('error', function (error) { + fs.appendFileSync('./gulp_error.log', 'Failed scss-to-css task\n' + error.message + '\n'); + console.error('Sass Compilation Error:', error.messageFormatted); + process.exit(1); + })) + .pipe(rename({ suffix: '.min' })) + .pipe(gulp.dest('./src/wwwroot/styles')) + .on('end', cleanup) + .on('error', cleanup); }); gulp.task('blazor-toolkit-themes', gulp.series('combined-scss', 'scss-to-css')); diff --git a/samples/Blazor.Toolkit.Samples.Client/Pages/Components/Inputs/TextArea.razor b/samples/Blazor.Toolkit.Samples.Client/Pages/Components/Inputs/TextArea.razor index fa4499d..429a723 100644 --- a/samples/Blazor.Toolkit.Samples.Client/Pages/Components/Inputs/TextArea.razor +++ b/samples/Blazor.Toolkit.Samples.Client/Pages/Components/Inputs/TextArea.razor @@ -213,14 +213,14 @@
- - + +
- - +
diff --git a/samples/Blazor.Toolkit.Samples.Client/Pages/Components/Inputs/TextBox.razor b/samples/Blazor.Toolkit.Samples.Client/Pages/Components/Inputs/TextBox.razor index 76e29d6..c6a3423 100644 --- a/samples/Blazor.Toolkit.Samples.Client/Pages/Components/Inputs/TextBox.razor +++ b/samples/Blazor.Toolkit.Samples.Client/Pages/Components/Inputs/TextBox.razor @@ -256,20 +256,20 @@
- - + +
- - + +
- - + +
- - + +
diff --git a/samples/Blazor.Toolkit.Samples.Client/Pages/Components/Inputs/Uploader.razor b/samples/Blazor.Toolkit.Samples.Client/Pages/Components/Inputs/Uploader.razor index 383b32c..5d7edd9 100644 --- a/samples/Blazor.Toolkit.Samples.Client/Pages/Components/Inputs/Uploader.razor +++ b/samples/Blazor.Toolkit.Samples.Client/Pages/Components/Inputs/Uploader.razor @@ -199,7 +199,6 @@ SequentialUpload="false" ShowFileList="true" ShowProgressBar="false" - TabIndex="1" FileSelected="OnFileSelected" BeforeUpload="OnBeforeUpload" BeforeRemove="OnBeforeRemove" @@ -445,7 +444,6 @@ SequentialUpload=""false"" ShowFileList=""true"" ShowProgressBar=""false"" - TabIndex=""1"" FileSelected=""OnFileSelected"" BeforeUpload=""OnBeforeUpload"" BeforeRemove=""OnBeforeRemove"" diff --git a/src/Components/Inputs/CheckBox/SfCheckBox.razor b/src/Components/Inputs/CheckBox/SfCheckBox.razor index 48169a5..6ef42c2 100644 --- a/src/Components/Inputs/CheckBox/SfCheckBox.razor +++ b/src/Components/Inputs/CheckBox/SfCheckBox.razor @@ -33,6 +33,7 @@ value="@Value" disabled="@Disabled" checked="@GetIsChecked()" + @onclick:preventDefault="true" aria-label="@GetAriaLabelValue()" aria-readonly="@GetAriaReadOnly()" aria-checked="@GetAriaChecked()" diff --git a/src/Components/Inputs/CheckBox/SfCheckBox.razor.LifeCycle.cs b/src/Components/Inputs/CheckBox/SfCheckBox.razor.LifeCycle.cs index 200f05f..6f60237 100644 --- a/src/Components/Inputs/CheckBox/SfCheckBox.razor.LifeCycle.cs +++ b/src/Components/Inputs/CheckBox/SfCheckBox.razor.LifeCycle.cs @@ -34,6 +34,7 @@ protected override async Task OnInitializedAsync() protected override async Task OnAfterRenderAsync(bool firstRender) { await base.OnAfterRenderAsync(firstRender).ConfigureAwait(false); + await InvokeVoidAsync(_checkBoxJsModule, _checkBoxInProcessModule, "syncIndeterminate", _input, Indeterminate).ConfigureAwait(true); if (firstRender && EnablePersistence) { try diff --git a/src/Components/Inputs/CheckBox/SfCheckBox.razor.cs b/src/Components/Inputs/CheckBox/SfCheckBox.razor.cs index fbe8638..9777457 100644 --- a/src/Components/Inputs/CheckBox/SfCheckBox.razor.cs +++ b/src/Components/Inputs/CheckBox/SfCheckBox.razor.cs @@ -470,7 +470,7 @@ private Dictionary GetAttributes(object titleValue) /// The next to transition to. /// /// - /// When tri-state is enabled, the cycle is: Checked → Indeterminate → Unchecked → Checked. + /// When tri-state is enabled, the cycle is: Checked → Unchecked → Indeterminate → Checked. /// /// /// When tri-state is disabled, the cycle is: Checked ⇄ Unchecked (standard two-state toggle). @@ -480,10 +480,10 @@ private static CheckboxState DetermineNextState(bool isChecked, bool isIndetermi { if (allowTriState) { - // Tri-state cycle: Checked → Indeterminate → Unchecked → Checked - return isChecked && !isIndeterminate - ? CheckboxState.Indeterminate - : isIndeterminate ? CheckboxState.Unchecked : CheckboxState.Checked; + // Tri-state cycle: Checked → Unchecked → Indeterminate → Checked + return isIndeterminate + ? CheckboxState.Checked + : isChecked ? CheckboxState.Unchecked : CheckboxState.Indeterminate; } // Two-state toggle: handle indeterminate as a transition state diff --git a/src/Components/Inputs/NumericTextBox/SfNumericTextBox.razor.LifeCycle.cs b/src/Components/Inputs/NumericTextBox/SfNumericTextBox.razor.LifeCycle.cs index 5e8c8a1..cb27172 100644 --- a/src/Components/Inputs/NumericTextBox/SfNumericTextBox.razor.LifeCycle.cs +++ b/src/Components/Inputs/NumericTextBox/SfNumericTextBox.razor.LifeCycle.cs @@ -69,7 +69,7 @@ protected override async Task OnParametersSetAsync() await base.OnParametersSetAsync().ConfigureAwait(true); UpdateValidateClass(); InputHtmlAttributes = SfBaseUtils.UpdateDictionary(ROLE, SPIN_BUTTON, InputHtmlAttributes); - InputHtmlAttributes = SfBaseUtils.UpdateDictionary(ARIA_LIVE, ASSERTIVE, InputHtmlAttributes); + //InputHtmlAttributes = SfBaseUtils.UpdateDictionary(ARIA_LIVE, ASSERTIVE, InputHtmlAttributes); if (!InputHtmlAttributes.ContainsKey(ARIA_LABEL) && !string.IsNullOrWhiteSpace(AriaLabel)) { // When AriaLabel is explicitly supplied, forward it. We deliberately do NOT diff --git a/src/Components/Inputs/NumericTextBox/SfNumericTextBox.razor.cs b/src/Components/Inputs/NumericTextBox/SfNumericTextBox.razor.cs index 0817914..423d136 100644 --- a/src/Components/Inputs/NumericTextBox/SfNumericTextBox.razor.cs +++ b/src/Components/Inputs/NumericTextBox/SfNumericTextBox.razor.cs @@ -573,6 +573,10 @@ private async Task ChangeValueAsync(TValue? value) /// private void UpdateValueAnnouncement() { + if (IsFocus) + { + return; + } string? formatted = FormatValueAsString(InputTextValue); if (string.IsNullOrEmpty(formatted) || string.Equals(formatted, _lastAnnouncedFormattedValue, StringComparison.Ordinal)) { @@ -1031,6 +1035,7 @@ protected override async Task FocusOutHandlerAsync(FocusEventArgs args) else { IsFocus = false; + _lastAnnouncedFormattedValue = null; string? inputValue = FocusInputValue; if (string.IsNullOrEmpty(inputValue)) { diff --git a/src/Components/Inputs/Switch/SfSwitch.razor b/src/Components/Inputs/Switch/SfSwitch.razor index 5cfefa2..b751e73 100644 --- a/src/Components/Inputs/Switch/SfSwitch.razor +++ b/src/Components/Inputs/Switch/SfSwitch.razor @@ -19,8 +19,7 @@ role="switch" aria-checked="@GetAriaPressed()" aria-disabled="@Disabled.ToString().ToLower()" - aria-labelledby="@(!string.IsNullOrWhiteSpace(Label) ? $"{_idValue}-label" : null)" - aria-label="@(string.IsNullOrWhiteSpace(Label) ? (string.IsNullOrWhiteSpace(AriaLabel) ? null : AriaLabel) : null)" + aria-label="@GetAccessibleName()" name="@Name" value="@Value" checked="@Checked" @@ -34,9 +33,4 @@ - - @if (!string.IsNullOrWhiteSpace(Label)) - { - - }
\ No newline at end of file diff --git a/src/Components/Inputs/Switch/SfSwitch.razor.cs b/src/Components/Inputs/Switch/SfSwitch.razor.cs index 394913b..4bd016f 100644 --- a/src/Components/Inputs/Switch/SfSwitch.razor.cs +++ b/src/Components/Inputs/Switch/SfSwitch.razor.cs @@ -245,11 +245,47 @@ protected override void InitRender(bool isDynamic = false) } } + /// + /// Computes the accessible name for the switch input. Exactly one source wins + /// (in priority order: , external , + /// then the currently-active / ). + /// + /// + /// Returning a single string — instead of joining multiple aria-labelledby ids — + /// eliminates the duplicate / cross-state announcements produced when both + /// state labels were present in the DOM (e.g. NVDA reading + /// "Open switch off, close switch off" on an off-to-on toggle). + /// + private string GetAccessibleName() + { + if (!string.IsNullOrWhiteSpace(AriaLabel)) + { + return AriaLabel; + } + + if (!string.IsNullOrWhiteSpace(Label)) + { + return Label; + } + + // Only the active state label is announced — both at once caused the bug. + bool isChecked = false; + try + { + isChecked = Checked is not null && Convert.ToBoolean(Checked, CultureInfo.InvariantCulture); + } + catch (Exception) + { + // Falls through to the off-state label below. + } + + string? active = isChecked ? OnLabel : OffLabel; + return string.IsNullOrWhiteSpace(active) ? string.Empty : active; + } + /// /// Updates the CSS classes for the switch's visual state (inner track and handle). - /// Applies active state classes when checked, removes them when unchecked. /// - /// State token: use CHECK for checked state, UNCHECK for unchecked. private void ChangeState(string state) { _innerClass = Inner; diff --git a/src/Components/Inputs/TextArea/SfTextArea.razor.cs b/src/Components/Inputs/TextArea/SfTextArea.razor.cs index 072f5ca..695c1de 100644 --- a/src/Components/Inputs/TextArea/SfTextArea.razor.cs +++ b/src/Components/Inputs/TextArea/SfTextArea.razor.cs @@ -63,6 +63,9 @@ public partial class SfTextArea : SfInputBase private const string ARIA_LABEL = "aria-label"; private const string ARIA_LABELLEDBY = "aria-labelledby"; private const string ARIA_DESCRIBEDBY = "aria-describedby"; + private const string ARIA_INVALID = "aria-invalid"; + private const string TRUE = "true"; + private const string ERROR_MESSAGE_ID_PREFIX = "err_"; private const string NULL_STRING = "null"; #endregion @@ -423,6 +426,44 @@ private void UpdateValidationClass() ContainerClass = !string.IsNullOrEmpty(_validClass) ? SfBaseUtils.AddClass(ContainerClass, _validClass) : ContainerClass; ContainerClass = WhitespaceRegex().Replace(ContainerClass, " "); ApplyValidationStateClasses(); + SyncInvalidAriaState(); + } + + /// + /// Surfaces the EditContext field validity to assistive technology (NVDA, Narrator) by + /// setting aria-invalid="true" and aria-describedby="err_<ID>" on the + /// rendered textarea. The err_<ID> token is a deterministic id the consumer + /// honors by assigning the same id to the framework <ValidationMessage> + /// element, so the screen reader reads the actual error text on the next focus. + /// + /// + /// Implements WCAG 3.3.1 (Error Identification) and 4.1.2 (Name, Role, Value). + /// When the field becomes valid, aria-invalid is removed entirely and any + /// err_ token previously appended to aria-describedby is preserved verbatim + /// (a user-supplied is never overwritten). + /// + private void SyncInvalidAriaState() + { + bool isInvalid = _validClass is INVALID or MODIFIED_INVALID; + if (isInvalid) + { + InputHtmlAttributes = SfBaseUtils.UpdateDictionary(ARIA_INVALID, TRUE, InputHtmlAttributes); + string errorId = ERROR_MESSAGE_ID_PREFIX + ID; + string existing = InputHtmlAttributes.TryGetValue(ARIA_DESCRIBEDBY, out object? v) && v is not null ? v.ToString() ?? string.Empty : string.Empty; + // Append the error id rather than overwriting any user-supplied + // AriaDescribedBy (e.g. a help-text id), but never duplicate it. + string merged = string.IsNullOrEmpty(existing) || existing.Contains(errorId, StringComparison.Ordinal) + ? errorId + : existing + " " + errorId; + InputHtmlAttributes = SfBaseUtils.UpdateDictionary(ARIA_DESCRIBEDBY, merged, InputHtmlAttributes); + } + else + { + // Use Dictionary.Remove so the attribute is fully omitted from + // the rendered textarea; UpdateDictionary would write the key + // back as null. + _ = InputHtmlAttributes.Remove(ARIA_INVALID); + } } /// diff --git a/src/Components/Inputs/TextBox/SfTextBox.razor.cs b/src/Components/Inputs/TextBox/SfTextBox.razor.cs index 0aff220..3d67755 100644 --- a/src/Components/Inputs/TextBox/SfTextBox.razor.cs +++ b/src/Components/Inputs/TextBox/SfTextBox.razor.cs @@ -43,6 +43,9 @@ public partial class SfTextBox : SfInputBase private const string ARIA_LABEL = "aria-label"; private const string ARIA_LABELLEDBY = "aria-labelledby"; private const string ARIA_DESCRIBEDBY = "aria-describedby"; + private const string ARIA_INVALID = "aria-invalid"; + private const string TRUE = "true"; + private const string ERROR_MESSAGE_ID_PREFIX = "err_"; #endregion @@ -444,6 +447,45 @@ private void UpdateValidateClass() ContainerClass = SfBaseUtils.RemoveClass(ContainerClass, ERROR_CLASS); ContainerClass = SfBaseUtils.RemoveClass(ContainerClass, SUCCESS_CLASS); } + SyncInvalidAriaState(); + } + } + + /// + /// Surfaces the EditContext field validity to assistive technology (NVDA, Narrator) by + /// setting aria-invalid="true" and aria-describedby="err_<ID>" on the + /// rendered input. The err_<ID> token is a deterministic id the consumer + /// honors by assigning the same id to the framework <ValidationMessage> + /// element, so the screen reader reads the actual error text on the next focus. + /// + /// + /// Implements WCAG 3.3.1 (Error Identification) and 4.1.2 (Name, Role, Value). + /// When the field becomes valid, aria-invalid is removed entirely and any + /// err_ token previously appended to aria-describedby is left in place + /// only if it was the sole value (a user-supplied is + /// never overwritten). + /// + private void SyncInvalidAriaState() + { + bool isInvalid = _validClass is INVALID or MODIFIED_INVALID; + if (isInvalid) + { + InputHtmlAttributes = SfBaseUtils.UpdateDictionary(ARIA_INVALID, TRUE, InputHtmlAttributes); + string errorId = ERROR_MESSAGE_ID_PREFIX + ID; + string existing = InputHtmlAttributes.TryGetValue(ARIA_DESCRIBEDBY, out object? v) && v is not null ? v.ToString() ?? string.Empty : string.Empty; + // Append the error id rather than overwriting any user-supplied + // AriaDescribedBy (e.g. a help-text id), but never duplicate it. + string merged = string.IsNullOrEmpty(existing) || existing.Contains(errorId, StringComparison.Ordinal) + ? errorId + : existing + SPACE + errorId; + InputHtmlAttributes = SfBaseUtils.UpdateDictionary(ARIA_DESCRIBEDBY, merged, InputHtmlAttributes); + } + else + { + // Use Dictionary.Remove so the attribute is fully omitted from + // the rendered input; UpdateDictionary would write the key + // back as null. + _ = InputHtmlAttributes.Remove(ARIA_INVALID); } } diff --git a/src/Components/Popups/Dialog/SfDialog.razor.cs b/src/Components/Popups/Dialog/SfDialog.razor.cs index 5f2d641..9ef5558 100644 --- a/src/Components/Popups/Dialog/SfDialog.razor.cs +++ b/src/Components/Popups/Dialog/SfDialog.razor.cs @@ -99,6 +99,7 @@ internal void UpdateTemplate(string name, RenderFragment template) { case nameof(Header): HeaderTemplate = template; + ResolveAccessibleName(); break; case nameof(Content): ContentTemplate = template; @@ -111,6 +112,34 @@ internal void UpdateTemplate(string name, RenderFragment template) } } + /// + /// Sets aria-labelledby to the built-in title element id when a + /// header (text or template) exists, otherwise falls back to + /// aria-label. Always clears any previous values first so the + /// resolution is idempotent across re-renders. + /// + private void ResolveAccessibleName() + { + if (_dialogAttribute is null) + { + return; + } + _dialogAttribute.Remove("aria-labelledby"); + _dialogAttribute.Remove("aria-label"); + string? labelledById = GetAriaLabelledBy(); + if (!string.IsNullOrWhiteSpace(labelledById)) + { + _dialogAttribute = SfBaseUtils.UpdateDictionary("aria-labelledby", labelledById, _dialogAttribute); + } + else + { + string resolvedAriaLabel = !string.IsNullOrWhiteSpace(AriaLabel) + ? AriaLabel + : (Localizer["Dialog"]?.Value ?? "Dialog"); + _dialogAttribute = SfBaseUtils.UpdateDictionary("aria-label", resolvedAriaLabel, _dialogAttribute); + } + } + internal void UpdateButtons(List buttons) { ButtonsValue = buttons; @@ -344,26 +373,10 @@ private void UpdateHtmlAttributes() } } } - // WCAG 4.1.2 Name, Role, Value — aria-label is the single source of truth. - // Honor the explicit AriaLabel parameter; otherwise fall back to a localized - // "Dialog" string only when the dictionary (and the consumer's HtmlAttributes) - // hasn't already supplied one. - if (_dialogAttribute is not null && !_dialogAttribute.ContainsKey("aria-label")) - { - string resolvedAriaLabel = !string.IsNullOrWhiteSpace(AriaLabel) - ? AriaLabel - : (Localizer["Dialog"]?.Value ?? "Dialog"); - _dialogAttribute = SfBaseUtils.UpdateDictionary("aria-label", resolvedAriaLabel, _dialogAttribute); - } - // aria-labelledby takes precedence over aria-label; only fall back when the - // dictionary and consumer-supplied attributes haven't pinned it down. - if (_dialogAttribute is not null && !_dialogAttribute.ContainsKey("aria-labelledby")) + if (_dialogAttribute is not null && !_dialogAttribute.ContainsKey("aria-labelledby") && + !_dialogAttribute.ContainsKey("aria-label")) { - string? labelledBy = GetAriaLabelledBy(); - if (!string.IsNullOrWhiteSpace(labelledBy)) - { - _dialogAttribute = SfBaseUtils.UpdateDictionary("aria-labelledby", labelledBy, _dialogAttribute); - } + ResolveAccessibleName(); } if (_dialogAttribute is not null && Visible && IsModal && IsStaticRendering) { diff --git a/src/wwwroot/scripts/checkbox.js b/src/wwwroot/scripts/checkbox.js index 29a4779..020258a 100644 --- a/src/wwwroot/scripts/checkbox.js +++ b/src/wwwroot/scripts/checkbox.js @@ -77,6 +77,12 @@ export function initialize(element, container) { instance.initialize(); } +export function syncIndeterminate(element, indeterminate) { + if (element) { + element.indeterminate = indeterminate === true; + } +} + export function destroy(element) { if (!element) return; const instance = element._blazorCheckBoxInstance; diff --git a/src/wwwroot/scripts/dialog.js b/src/wwwroot/scripts/dialog.js index 735f9b3..d311efc 100644 --- a/src/wwwroot/scripts/dialog.js +++ b/src/wwwroot/scripts/dialog.js @@ -463,7 +463,7 @@ var SfDialog = (function () { this.dlgContainer.style.zIndex = zIndex.toString(); }; /** - * Move focus into the dialog content according to autofocus rules + * Move focus into the dialog content according to autofocus rules. */ SfDialog.prototype.focusContent = function () { var element = this.getAutoFocusNode(this.element); @@ -472,29 +472,21 @@ var SfDialog = (function () { this.hasFocusableNode = true; }; /** - * Find the best candidate node to autofocus inside the dialog + * Find the best candidate node to autofocus inside the dialog. * @param {Element} container - Dialog container element * @returns {Element|null} */ SfDialog.prototype.getAutoFocusNode = function (container) { - var node = container.querySelector('.' + DLG_CLOSE_ICON_BTN); - var value = '[autofocus]'; - var items = container.querySelectorAll(value); - var validNode = this.getValidFocusNode(items); this.primaryButtonEle = this.element.getElementsByClassName(PRIMARY)[0]; + var validNode = this.getValidFocusNode(container.querySelectorAll('[autofocus]')); if (!sfBlazorToolkit.base.isNullOrUndefined(validNode)) { - node = validNode; + return validNode; } - else { - validNode = this.focusableElements(this.contentEle); - if (!sfBlazorToolkit.base.isNullOrUndefined(validNode)) { - return node = validNode; - } - else if (!sfBlazorToolkit.base.isNullOrUndefined(this.primaryButtonEle)) { - return this.element.querySelector('.' + PRIMARY); - } + validNode = this.focusableElements(this.contentEle); + if (!sfBlazorToolkit.base.isNullOrUndefined(validNode)) { + return validNode; } - return node; + return null; }; SfDialog.prototype.getValidFocusNode = function (items) { var node; @@ -534,7 +526,6 @@ var SfDialog = (function () { * @returns {string|null} */ SfDialog.prototype.getMaxHeight = function () { - this.storeActiveElement = document.activeElement; if (!sfBlazorToolkit.base.isNullOrUndefined(this.element) && !sfBlazorToolkit.base.isNullOrUndefined(this.element.style)) { return this.element.style.maxHeight; } @@ -616,6 +607,24 @@ var SfDialog = (function () { } } }; + SfDialog.prototype.captureActiveElement = function () { + if (this.preventFocus) { + return; + } + var active = document.activeElement; + if (sfBlazorToolkit.base.isNullOrUndefined(active) || active === document.body) { + return; + } + if (!document.contains(active)) { + return; + } + if (!sfBlazorToolkit.base.isNullOrUndefined(this.storeActiveElement) + && this.storeActiveElement !== document.body + && document.contains(this.storeActiveElement)) { + return; + } + this.storeActiveElement = active; + }; /** * Show the dialog (opens popup and applies animation) * @param {boolean} [isFullScreen] - Whether to open in fullscreen mode @@ -624,6 +633,7 @@ var SfDialog = (function () { SfDialog.prototype.show = function (isFullScreen, maxHeight) { this.isOpenFullScreen = isFullScreen; if (!this.element.classList.contains(POPUP_OPEN) || !sfBlazorToolkit.base.isNullOrUndefined(isFullScreen)) { + this.captureActiveElement(); if (!sfBlazorToolkit.base.isNullOrUndefined(isFullScreen)) { this.fullScreen(isFullScreen); } @@ -639,6 +649,8 @@ var SfDialog = (function () { this.dlgOverlay.style.display = 'block'; this.dlgContainer.style.display = 'flex'; sfBlazorToolkit.base.removeClass(this.dlgOverlay, FADE); + this.dlgOverlay.setAttribute('tabindex', '-1'); + this.dlgOverlay.setAttribute('aria-hidden', 'true'); if (!sfBlazorToolkit.base.isNullOrUndefined(this.targetEle)) { if (this.targetEle === document.body) { this.dlgContainer.style.position = 'fixed'; @@ -740,6 +752,7 @@ var SfDialog = (function () { SfDialog.prototype.destroy = function (dlgObj, isClientApp) { if (this.element instanceof Element || this.element instanceof HTMLElement) { this.updateContext(dlgObj); + this.popupCloseHandler(); var attrs = ['role', 'aria-modal', 'aria-labelledby', 'aria-describedby', 'aria-grabbed', 'tabindex', 'style']; if (!sfBlazorToolkit.base.isNullOrUndefined(this.cssClass) && this.cssClass !== '') { var classes = this.cssClass.split(' '); @@ -854,13 +867,17 @@ var SfDialog = (function () { }; /* Event handlers begin */ SfDialog.prototype.popupCloseHandler = function () { - var activeEle = document.activeElement; - if (!this.preventFocus && !sfBlazorToolkit.base.isNullOrUndefined(activeEle) && !sfBlazorToolkit.base.isNullOrUndefined(activeEle.blur)) { - activeEle.blur(); + if (this.preventFocus) { + this.storeActiveElement = null; + return; } - if (!this.preventFocus && !sfBlazorToolkit.base.isNullOrUndefined(this.storeActiveElement) && !sfBlazorToolkit.base.isNullOrUndefined(this.storeActiveElement.focus)) { + if (!sfBlazorToolkit.base.isNullOrUndefined(this.storeActiveElement) + && !sfBlazorToolkit.base.isNullOrUndefined(this.storeActiveElement.focus) + && document.contains(this.storeActiveElement) + && (sfBlazorToolkit.base.isNullOrUndefined(this.element) || !this.element.contains(this.storeActiveElement))) { this.storeActiveElement.focus(); } + this.storeActiveElement = null; }; SfDialog.prototype.windowResizeHandler = function () { sfBlazorToolkit.Resize.setMaxWidth(this.targetEle.clientWidth); @@ -911,31 +928,31 @@ var SfDialog = (function () { SfDialog.prototype.keyDown = function (e) { var _this = this; if (e.keyCode === TAB && this.isModal) { - var btn = void 0; - var btns = void 0; - var footer = this.element.querySelector('.' + FOOTER_CONTENT); - if (!sfBlazorToolkit.base.isNullOrUndefined(footer)) { - btns = footer.querySelectorAll('button'); - if (!sfBlazorToolkit.base.isNullOrUndefined(btn) && btns.length > 0) { - btn = btns[btns.length - 1]; - } - if (sfBlazorToolkit.base.isNullOrUndefined(btn) && footer.childNodes.length > 0) { - btn = this.getFocusElement(footer); - } - } - if (sfBlazorToolkit.base.isNullOrUndefined(footer) && !sfBlazorToolkit.base.isNullOrUndefined(this.contentEle)) { - btn = this.getFocusElement(this.contentEle); - } - if (!sfBlazorToolkit.base.isNullOrUndefined(btn) && document.activeElement === btn && !e.shiftKey) { + // WCAG 2.4.3 / WAI-ARIA modal-dialog: trap focus inside the dialog. + var focusableSelector = 'input:not([disabled]):not([type=\"hidden\"]),select:not([disabled]),textarea:not([disabled]),button:not([disabled]),a[href],[contenteditable=\"true\"],[tabindex]:not([tabindex=\"-1\"])'; + var focusable = Array.prototype.slice.call(this.element.querySelectorAll(focusableSelector)) + .filter(function (el) { + return el.offsetWidth > 0 || el.offsetHeight > 0 || el === document.activeElement; + }); + if (focusable.length === 0) { e.preventDefault(); - this.focusableElements(this.element).focus(); + this.element.focus(); + return; } - if (document.activeElement === this.focusableElements(this.element) && e.shiftKey) { - e.preventDefault(); - if (!sfBlazorToolkit.base.isNullOrUndefined(btn)) { - btn.focus(); + var first = focusable[0]; + var last = focusable[focusable.length - 1]; + var active = document.activeElement; + if (e.shiftKey) { + if (active === first || !this.element.contains(active)) { + e.preventDefault(); + last.focus(); } } + else if (active === last || !this.element.contains(active)) { + e.preventDefault(); + first.focus(); + } + return; } if (e.keyCode === ESCAPE && this.closeOnEscape) { // 'document.querySelector' is used to find the elements rendered based on body @@ -945,6 +962,7 @@ var SfDialog = (function () { repeat: e.repeat, shiftKey: e.shiftKey, metaKey: e.metaKey, type: e.type }); } + return; } if (this.hasFocusableNode) { var element = document.activeElement; diff --git a/src/wwwroot/scripts/resize.js b/src/wwwroot/scripts/resize.js index c5f9283..d92d615 100644 --- a/src/wwwroot/scripts/resize.js +++ b/src/wwwroot/scripts/resize.js @@ -32,7 +32,13 @@ const Resize = (function (exports) { iconClass += 'e-resizer-right '; } if (dialogBorderResize.indexOf(d) >= 0) setBorderResizeElm(d); - else targetElement.appendChild(sfBlazorToolkit.base.createElement('div', { className: iconClass + RESIZE_HANDLER + ' e-' + d })); + else { + var handle = sfBlazorToolkit.base.createElement('div', { className: iconClass + RESIZE_HANDLER + ' e-' + d }); + handle.setAttribute('aria-hidden', 'true'); + handle.setAttribute('role', 'presentation'); + handle.setAttribute('tabindex', '-1'); + targetElement.appendChild(handle); + } }); minHeight = args.minHeight; minWidth = args.minWidth; maxWidth = args.maxWidth; maxHeight = args.maxHeight; resizeCount++; @@ -43,6 +49,9 @@ const Resize = (function (exports) { calculateValues(); const span = sfBlazorToolkit.base.createElement('span', { className: 'e-dialog-border-resize e-' + direction }); span.setAttribute('unselectable', 'on'); span.setAttribute('contenteditable', 'false'); + span.setAttribute('aria-hidden', 'true'); + span.setAttribute('role', 'presentation'); + span.setAttribute('tabindex', '-1'); if (direction === 'south' || direction === 'north') { Object.assign(span.style, { height: '2px', width: '100%', left: '0px' }); span.style[direction === 'south' ? 'bottom' : 'top'] = '0px'; diff --git a/src/wwwroot/scripts/uploader.js b/src/wwwroot/scripts/uploader.js index fabd674..301a971 100644 --- a/src/wwwroot/scripts/uploader.js +++ b/src/wwwroot/scripts/uploader.js @@ -1795,12 +1795,14 @@ var SfUploader = /** @class */ (function () { } fileContainer.appendChild(infoEle); if (sfBlazorToolkit.base.isNullOrUndefined(fileList.querySelector('.e-toolkit-icons'))) { - var iconElement = sfBlazorToolkit.base.createElement('span', { className: 'e-toolkit-icons', attrs: { 'tabindex': this.btnTabIndex } }); + var iconElement = sfBlazorToolkit.base.createElement('span', { className: 'e-toolkit-icons' }); if (this.browserName === 'msie') { iconElement.classList.add('e-msie'); } + iconElement.setAttribute('tabindex', this.btnTabIndex); iconElement.setAttribute('title', this.localizedTexts('remove')); iconElement.setAttribute('aria-label', this.localizedTexts('remove')); + iconElement.setAttribute('role', 'button'); fileList.appendChild(fileContainer); fileList.appendChild(iconElement); sfBlazorToolkit.base.EventHandler.add(iconElement, 'click', this.removeFiles, this); @@ -1969,12 +1971,12 @@ var SfUploader = /** @class */ (function () { statusElement.innerHTML = listItem.status; liElement.appendChild(textContainer); var iconElement = sfBlazorToolkit.base.createElement('span', { - className: ' e-toolkit-icons', - attrs: { 'tabindex': this.btnTabIndex } + className: ' e-toolkit-icons' }); if (this.browserName === 'msie') { iconElement.classList.add('e-msie'); } + iconElement.setAttribute('tabindex', this.btnTabIndex); iconElement.setAttribute('title', this.localizedTexts('remove')); iconElement.setAttribute('aria-label', this.localizedTexts('remove')); iconElement.setAttribute('role', 'button'); @@ -2264,8 +2266,9 @@ var SfUploader = /** @class */ (function () { deleteIcon.classList.add(REMOVE_ICON); deleteIcon.setAttribute('title', this.localizedTexts('remove')); deleteIcon.setAttribute('aria-label', this.localizedTexts('remove')); - this.pauseButton = sfBlazorToolkit.base.createElement('span', { className: 'e-toolkit-icons e-refresh', attrs: { 'tabindex': this.btnTabIndex } }); + this.pauseButton = sfBlazorToolkit.base.createElement('span', { className: 'e-toolkit-icons e-refresh' }); deleteIcon.parentElement.insertBefore(this.pauseButton, deleteIcon); + this.pauseButton.setAttribute('tabindex', this.btnTabIndex); this.pauseButton.setAttribute('title', this.localizedTexts('retry')); this.pauseButton.setAttribute('aria-label', this.localizedTexts('retry')); var retryElement = liElement.querySelector('.' + RETRY_ICON); @@ -2672,9 +2675,10 @@ var SfUploader = /** @class */ (function () { liElement.querySelector('.' + STATUS).classList.add(UPLOAD_FAILED); eventArgs.fileData.statusCode = '5'; eventArgs.fileData.status = this.localizedTexts('fileUploadCancel'); - this.pauseButton = sfBlazorToolkit.base.createElement('span', { className: 'e-toolkit-icons e-refresh', attrs: { 'tabindex': this.btnTabIndex } }); + this.pauseButton = sfBlazorToolkit.base.createElement('span', { className: 'e-toolkit-icons e-refresh' }); var removeIcon = liElement.querySelector('.' + REMOVE_ICON); removeIcon.parentElement.insertBefore(this.pauseButton, removeIcon); + this.pauseButton.setAttribute('tabindex', this.btnTabIndex); this.pauseButton.setAttribute('title', this.localizedTexts('retry')); this.pauseButton.setAttribute('aria-label', this.localizedTexts('retry')); this.pauseButton.addEventListener('click', function (e) { _this.reloadcanceledFile(e, file, liElement); }, false); @@ -3113,12 +3117,13 @@ var SfUploader = /** @class */ (function () { } if (sfBlazorToolkit.base.isNullOrUndefined(liElement.querySelector('.' + PAUSE_UPLOAD)) && sfBlazorToolkit.base.isNullOrUndefined(this.template) && sfBlazorToolkit.base.isNullOrUndefined(liElement.querySelector('.' + DELETE_ICON))) { - this.pauseButton = sfBlazorToolkit.base.createElement('span', { className: 'e-toolkit-icons e-pause', attrs: { 'tabindex': this.btnTabIndex } }); + this.pauseButton = sfBlazorToolkit.base.createElement('span', { className: 'e-toolkit-icons e-pause' }); if (this.browserName === 'msie') { this.pauseButton.classList.add('e-msie'); } var abortIcon = liElement.querySelector('.' + ABORT_ICON); abortIcon.parentElement.insertBefore(this.pauseButton, abortIcon); + this.pauseButton.setAttribute('tabindex', this.btnTabIndex); this.pauseButton.setAttribute('title', this.localizedTexts('pause')); this.pauseButton.setAttribute('aria-label', this.localizedTexts('pause')); this.pauseButton.addEventListener('click', function (e) { _this.checkPausePlayAction(e); }, false); diff --git a/src/wwwroot/styles/base.scss b/src/wwwroot/styles/base.scss index 1fb0857..bc7586e 100644 --- a/src/wwwroot/styles/base.scss +++ b/src/wwwroot/styles/base.scss @@ -10,6 +10,11 @@ @return var(#{'--e-'+ $pallete-name}); } +// Layer priority declaration. Must match the order in highcontrast-tokens.scss +@layer fluent, themes; + +@layer fluent { + :root { --color-sf-primary: #0f6cbd; --color-sf-primary-text-color: #fff; @@ -218,6 +223,8 @@ --color-sf-skeleton-bg-color: #e6e6e6; } +} + @media (forced-colors: active) { :root { color-scheme: light dark; diff --git a/src/wwwroot/styles/highcontrast-tokens.scss b/src/wwwroot/styles/highcontrast-tokens.scss new file mode 100644 index 0000000..10ea21c --- /dev/null +++ b/src/wwwroot/styles/highcontrast-tokens.scss @@ -0,0 +1,396 @@ + +@layer fluent, themes; + +@layer themes { + +:root { + // color-scheme drives native chrome (scrollbars, form controls, etc.) + // and is inherited by .e-control / .e-css sub-scopes below. + color-scheme: dark; + + // === Brand === (matches the Microsoft HC standard for elements) + --color-sf-primary: #ffd939; + --color-sf-primary-text-color: #000; + --color-sf-primary-light: #ffd939; + --color-sf-primary-lighter: #685708; + --color-sf-primary-dark: #ffd939; + + // === Semantic status colors === + --color-sf-success: #166600; + --color-sf-info: #0056b3; + --color-sf-warning: #944000; + --color-sf-danger: #b30900; + + // === Content backgrounds === + --color-sf-content-bg-color: #333; + --color-sf-content-bg-color-alt1: #000; + --color-sf-content-bg-color-alt2: #1a1a1a; + --color-sf-content-bg-color-alt3: #262626; + --color-sf-content-bg-color-alt4: #0d0d0d; + --color-sf-content-bg-color-hover: #685708; + --color-sf-content-bg-color-pressed: #ffd939; + --color-sf-content-bg-color-focus: #685708; + --color-sf-content-bg-color-selected: #685708; + + --color-sf-flyout-bg-color: #000; + --color-sf-flyout-bg-color-hover: #685708; + --color-sf-flyout-bg-color-pressed: #ffd939; + --color-sf-flyout-bg-color-selected: #685708; + --color-sf-flyout-bg-color-focus: #685708; + --color-sf-overlay-bg-color: rgba(0, 0, 0, .75); + + // === Content text === + --color-sf-content-text-color: #fff; + --color-sf-content-text-color-alt1: #fff; + --color-sf-content-text-color-alt2: #bfbfbf; + --color-sf-content-text-color-alt3: #969696; + --color-sf-content-text-color-hover: #fff; + --color-sf-content-text-color-selected: #000; + --color-sf-content-text-color-disabled: #757575; + --color-sf-placeholder-text-color: #bfbfbf; + --color-sf-flyout-text-color-selected: #000; + --color-sf-flyout-text-color-focus: #fff; + --color-sf-flyout-text-color-disabled: #757575; + + // === Icons === + --color-sf-icon-color: #fff; + --color-sf-icon-color-hover: #ffd939; + --color-sf-icon-color-pressed: #000; + --color-sf-icon-color-disabled: #757575; + + // === Borders === + --color-sf-border-light: #969696; + --color-sf-border: #969696; + --color-sf-border-alt: #fff; + --color-sf-border-dark: #fff; + --color-sf-border-hover: #fff; + --color-sf-border-pressed: #ffd939; + --color-sf-border-disabled: #757575; + --color-sf-border-warning: #ff7d1a; + --color-sf-border-error: #ff6161; + --color-sf-border-success: #2bc700; + --color-sf-flyout-border: #fff; + + // === Primary button === + --color-sf-primary-bg-color: #ffd939; + --color-sf-primary-border-color: #ffd939; + --color-sf-primary-text: #000; + --color-sf-primary-bg-color-hover: #685708; + --color-sf-primary-border-color-hover: #fff; + --color-sf-primary-text-hover: #fff; + --color-sf-primary-bg-color-pressed: #ffd939; + --color-sf-primary-border-color-pressed: #ffd939; + --color-sf-primary-text-pressed: #000; + --color-sf-primary-bg-color-focus: #685708; + --color-sf-primary-border-color-focus: #fff; + --color-sf-primary-text-focus: #fff; + --color-sf-primary-bg-color-disabled: #333; + --color-sf-primary-border-color-disabled: #757575; + --color-sf-primary-text-disabled: #757575; + --color-sf-primary-bg-color-selected: #685708; + --color-sf-primary-border-color-selected: #fff; + --color-sf-primary-text-selected: #fff; + + // === Success === + --color-sf-success-bg-color: #166600; + --color-sf-success-border-color: #166600; + --color-sf-success-text: #fff; + --color-sf-success-bg-color-hover: #685708; + --color-sf-success-border-color-hover: #fff; + --color-sf-success-text-hover: #fff; + --color-sf-success-bg-color-pressed: #166600; + --color-sf-success-border-color-pressed: #ffd939; + --color-sf-success-text-pressed: #fff; + --color-sf-success-bg-color-focus: #685708; + --color-sf-success-border-color-focus: #fff; + --color-sf-success-text-focus: #fff; + --color-sf-success-bg-color-disabled: #333; + --color-sf-success-border-color-disabled: #757575; + --color-sf-success-text-disabled: #757575; + --color-sf-success-bg-color-selected: #685708; + --color-sf-success-border-color-selected: #fff; + --color-sf-success-text-selected: #fff; + + // === Warning === + --color-sf-warning-bg-color: #944000; + --color-sf-warning-border-color: #944000; + --color-sf-warning-text: #fff; + --color-sf-warning-bg-color-hover: #685708; + --color-sf-warning-border-color-hover: #fff; + --color-sf-warning-text-hover: #fff; + --color-sf-warning-bg-color-pressed: #944000; + --color-sf-warning-border-color-pressed: #ffd939; + --color-sf-warning-text-pressed: #fff; + --color-sf-warning-bg-color-focus: #685708; + --color-sf-warning-border-color-focus: #fff; + --color-sf-warning-text-focus: #fff; + --color-sf-warning-bg-color-disabled: #333; + --color-sf-warning-border-color-disabled: #757575; + --color-sf-warning-text-disabled: #757575; + --color-sf-warning-bg-color-selected: #685708; + --color-sf-warning-border-color-selected: #fff; + --color-sf-warning-text-selected: #fff; + + // === Info === + --color-sf-info-bg-color: #0056b3; + --color-sf-info-border-color: #0056b3; + --color-sf-info-text: #fff; + --color-sf-info-bg-color-hover: #685708; + --color-sf-info-border-color-hover: #fff; + --color-sf-info-text-hover: #fff; + --color-sf-info-bg-color-pressed: #0056b3; + --color-sf-info-border-color-pressed: #ffd939; + --color-sf-info-text-pressed: #fff; + --color-sf-info-bg-color-focus: #685708; + --color-sf-info-border-color-focus: #fff; + --color-sf-info-text-focus: #fff; + --color-sf-info-bg-color-disabled: #333; + --color-sf-info-border-color-disabled: #757575; + --color-sf-info-text-disabled: #757575; + --color-sf-info-bg-color-selected: #685708; + --color-sf-info-border-color-selected: #fff; + --color-sf-info-text-selected: #fff; + + // === Danger === + --color-sf-danger-bg-color: #b30900; + --color-sf-danger-border-color: #b30900; + --color-sf-danger-text: #fff; + --color-sf-danger-bg-color-hover: #685708; + --color-sf-danger-border-color-hover: #fff; + --color-sf-danger-text-hover: #fff; + --color-sf-danger-bg-color-pressed: #b30900; + --color-sf-danger-border-color-pressed: #ffd939; + --color-sf-danger-text-pressed: #fff; + --color-sf-danger-bg-color-focus: #685708; + --color-sf-danger-border-color-focus: #fff; + --color-sf-danger-text-focus: #fff; + --color-sf-danger-bg-color-disabled: #333; + --color-sf-danger-border-color-disabled: #757575; + --color-sf-danger-text-disabled: #757575; + --color-sf-danger-bg-color-selected: #685708; + --color-sf-danger-border-color-selected: #fff; + --color-sf-danger-text-selected: #fff; + + // === Secondary === + --color-sf-secondary-bg-color: #333; + --color-sf-secondary-border-color: #969696; + --color-sf-secondary-text-color: #fff; + --color-sf-secondary-bg-color-hover: #685708; + --color-sf-secondary-border-color-hover: #fff; + --color-sf-secondary-text-color-hover: #fff; + --color-sf-secondary-bg-color-pressed: #ffd939; + --color-sf-secondary-border-color-pressed: #ffd939; + --color-sf-secondary-text-color-pressed: #000; + --color-sf-secondary-bg-color-focus: #685708; + --color-sf-secondary-border-color-focus: #fff; + --color-sf-secondary-text-color-focus: #fff; + --color-sf-secondary-bg-color-disabled: #333; + --color-sf-secondary-border-color-disabled: #757575; + --color-sf-secondary-text-color-disabled: #757575; + --color-sf-secondary-bg-color-selected: #685708; + --color-sf-secondary-border-color-selected: #fff; + --color-sf-secondary-text-color-selected: #fff; + + // === Tooltip & shadow & link & skeleton & slider & switch === + --color-sf-tooltip-bg-color: #000; + --color-sf-tooltip-border: #fff; + --color-sf-tooltip-text-color: #fff; + --color-sf-shadow-color: #fff; + --color-sf-shadow-color1: #000; + --color-sf-link-button: #8a8aff; + + --color-sf-rating-selected-color: #ffd939; + --color-sf-rating-unrated-color: #fff; + --color-sf-rating-selected-disabled-color: #969696; + --color-sf-rating-unrated-disabled-color: #757575; + --color-sf-rating-selected-hover-color: #fff; + --color-sf-utility-primary-lighter: #685708; + --color-sf-skeleton-bg-color: #262626; + + --color-sf-slider-shadow1: #000; + --color-sf-slider-shadow2: #969696; + --color-sf-slider-border-color: #ffffff00; + --color-sf-slider-bg-disabled: #262626; + --color-sf-toggle-switch-border-disabled: #262626; + + // === Calendar / Dialog === + --color-sf-calendar-icon-color: #ffd939; + --color-sf-calendar-header-text-color-pressed: #ffd939; + --color-sf-dialog-border: #000; + + // === Secondary outline button text states (focus/hover/pressed/selected) === + --color-sf-secondary-outline-button-text-color-hover: #fff; + --color-sf-secondary-outline-button-text-color-pressed: #fff; + --color-sf-secondary-outline-button-text-color-selected: #fff; + --color-sf-secondary-outline-button-text-color-focus: #fff; +} + +// Reserve a separate scope for HC-light variant. Even though this currently +// shadows nothing (same values as :root above), keeping it isolated gives us +// a clean divergence point per the project requirement to support both +// highcontrast AND highcontrast-light themes. +html.highcontrast-light { + color-scheme: light; + --color-sf-content-bg-color: #cccccc; + --color-sf-content-bg-color-alt1: #ffffff; + --color-sf-content-bg-color-alt2: #f5f5f5; + --color-sf-content-bg-color-alt3: #ebebeb; + --color-sf-content-bg-color-alt4: #fafafa; + --color-sf-flyout-bg-color: #ffffff; + --color-sf-overlay-bg-color: rgba(255, 255, 255, .75); + --color-sf-content-text-color: #000; + --color-sf-content-text-color-alt1: #000; + --color-sf-content-text-color-alt2: #404040; + --color-sf-content-text-color-alt3: #666666; + --color-sf-placeholder-text-color: #666666; + --color-sf-icon-color: #000; + --color-sf-border-light: #666666; + --color-sf-border: #666666; + --color-sf-border-alt: #000; + --color-sf-border-dark: #000; + --color-sf-border-hover: #000; + --color-sf-flyout-border: #000; + --color-sf-flyout-bg-color-hover: #685708; + --color-sf-flyout-bg-color-pressed: #ffd939; + --color-sf-flyout-bg-color-selected: #685708; + --color-sf-flyout-bg-color-focus: #685708; + --color-sf-primary-border-color-hover: #000; + --color-sf-primary-bg-color-disabled: #cccccc; + --color-sf-primary-border-color-disabled: #757575; + --color-sf-primary-text-disabled: #757575; + --color-sf-secondary-bg-color: #ffffff; + --color-sf-secondary-border-color: #666666; + --color-sf-secondary-text-color: #000; + // HC-light outline button hover. The fluent rule sets background to + // transparent and uses --color-sf-secondary-border-color-hover for the + // border and --color-sf-secondary-outline-button-text-color-hover for + // the text. The HC-dark values (#fff/#fff) are invisible on a white + // page background, so force dark colors in HC-light. + --color-sf-secondary-bg-color-hover: #685708; + --color-sf-secondary-border-color-hover: #000; + --color-sf-secondary-outline-button-text-color-hover: #000; + --color-sf-secondary-outline-button-text-color-focus: #000; + --color-sf-secondary-outline-button-text-color-pressed: #000; + --color-sf-secondary-outline-button-text-color-selected: #000; + --color-sf-tooltip-bg-color: #ffffff; + --color-sf-tooltip-border: #000; + --color-sf-tooltip-text-color: #000; + --color-sf-shadow-color: #000; + --color-sf-shadow-color1: #fff; + --color-sf-calendar-icon-color: #ffd939; + --color-sf-skeleton-bg-color: #ebebeb; +} + +} // end @layer themes + +// Unscoped component overrides (component-state corrections that var() tokens +// alone can't express). These rules live OUTSIDE @layer themes on purpose so +// they win by source order over the fluent component rules in the same sheet +// (CSS layer priority puts unlayered styles above @layer'd ones, and the HC +// sheet loads after the fluent sheet, so unlayered HC rules beat unlayered +// fluent rules of equal specificity). + +.e-calendar .e-header .e-prev, +.e-calendar .e-header .e-next { + background: transparent; + color: var(--color-sf-content-text-color-alt1); +} +.e-calendar .e-header .e-title { + color: var(--color-sf-content-text-color); +} +.e-calendar .e-header .e-arrow-up, +.e-calendar .e-header .e-arrow-down { + color: var(--color-sf-icon-color); +} +.e-calendar .e-header .e-prev:hover, +.e-calendar .e-header .e-prev:hover span, +.e-calendar .e-header .e-next:hover, +.e-calendar .e-header .e-next:hover span { + background: var(--color-sf-primary); + color: var(--color-sf-primary-text-color); +} +.e-calendar .e-header .e-title:hover, +.e-calendar .e-header .e-title:focus, +.e-calendar .e-header .e-title:active { + background: var(--color-sf-primary); + color: var(--color-sf-primary-text-color); +} +.e-calendar .e-content.e-decade td.e-selected > span.e-day, +.e-calendar .e-content.e-year td.e-selected > span.e-day { + background: var(--color-sf-primary); + color: var(--color-sf-primary-text-color); +} +.e-timepicker.e-popup .e-list-parent.e-ul .e-list-item.e-active { + background: var(--color-sf-primary); + color: var(--color-sf-primary-text-color); +} +.e-dialog .e-popup .e-dlg-content, +.e-dialog .e-popup .e-dlg-header-content { + color: var(--color-sf-content-text-color); +} +.e-checkbox-wrapper:hover .e-frame.e-check { + background-color: var(--color-sf-primary-bg-color-hover); + border-color: var(--color-sf-primary-border-color-hover); + color: var(--color-sf-content-text-color-hover); +} + +// Forced-colors accessibility fallback (Windows HighContrast et al.). +// When the OS forces colors, give every toolkit component a contrasting +// outline and let the OS provide the actual fill colors. +@media (forced-colors: active) { + :root { + color-scheme: light dark; + } + + .e-btn, + .e-calendar, + .e-checkbox-wrapper, + .e-datepicker, + .e-datetimepicker, + .e-dialog, + .e-input, + .e-popup, + .e-radio-wrapper, + .e-spinner, + .e-switch-wrapper, + .e-textarea, + .e-textbox, + .e-timepicker, + .e-tooltip { + border-color: CanvasText; + background-color: Canvas; + color: CanvasText; + } + + .e-btn.e-danger, + .e-btn.e-info, + .e-btn.e-primary, + .e-btn.e-success, + .e-btn.e-warning { + border-color: Highlight; + background-color: Highlight; + color: HighlightText; + } + + .e-btn:focus, + .e-btn:focus-visible, + .e-checkbox-wrapper:focus-within, + .e-datepicker:focus, + .e-datetimepicker:focus, + .e-input:focus, + .e-radio-wrapper:focus-within, + .e-switch-wrapper:focus-within, + .e-textarea:focus, + .e-textbox:focus, + .e-timepicker:focus { + outline: 2px solid Highlight; + outline-offset: 2px; + } + + .e-calendar, + .e-dialog, + .e-popup, + .e-tooltip { + box-shadow: none; + } +} diff --git a/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/advanced-scenarios.spec.ts b/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/advanced-scenarios.spec.ts index c0cafc2..7620178 100644 --- a/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/advanced-scenarios.spec.ts +++ b/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/advanced-scenarios.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { checkCheckbox, uncheckCheckbox } from './checkbox-helpers'; test.describe('Label Accessibility & Special Cases', () => { test.beforeEach(async ({ page }) => { @@ -66,10 +67,10 @@ test.describe('Custom Attributes & Advanced Properties', () => { await expect(checkbox).toBeVisible(); - await checkbox.check(); + await checkCheckbox(checkbox); await expect(checkbox).toBeChecked(); - await checkbox.uncheck(); + await uncheckCheckbox(checkbox); await expect(checkbox).not.toBeChecked(); }); }); \ No newline at end of file diff --git a/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/basic-functionality.spec.ts b/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/basic-functionality.spec.ts index c56824d..ad8a241 100644 --- a/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/basic-functionality.spec.ts +++ b/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/basic-functionality.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { checkCheckbox } from './checkbox-helpers'; test.describe('Checkbox – Basic Functionality', () => { @@ -23,8 +24,8 @@ test.describe('Checkbox – Basic Functionality', () => { test('Each checkbox maintains independent state', async ({ page }) => { const checkboxes = page.locator('input[type="checkbox"]'); - await checkboxes.nth(0).check(); - await checkboxes.nth(1).check(); + await checkCheckbox(checkboxes.nth(0)); + await checkCheckbox(checkboxes.nth(1)); await expect(checkboxes.nth(0)).toBeChecked(); await expect(checkboxes.nth(1)).toBeChecked(); diff --git a/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/checkbox-helpers.ts b/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/checkbox-helpers.ts new file mode 100644 index 0000000..2308db7 --- /dev/null +++ b/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/checkbox-helpers.ts @@ -0,0 +1,59 @@ +// checkbox-helpers.ts +// +// SfCheckBox uses @onclick:preventDefault="true" and drives its state from +// HandleClickAsync. Playwright's Locator.check() / uncheck() click the input +// and then assert the native `checked` property, but they race with the +// Blazor re-render and fail with "Clicking the checkbox did not change its +// state" when the click handler has not yet completed. +// +// These helpers are the minimal replacement: a single .click() that drives +// the Blazor click handler directly, and a tri-state-aware locator-state +// reader used by the intermediate-state assertion in grouping-hierarchy. + +import { Locator, expect } from '@playwright/test'; + +/** + * Reads the current tri-state of an SfCheckBox. + * Returns "indeterminate" when aria-checked is "mixed", otherwise the + * boolean state of the underlying input. + */ +export async function getCheckboxState( + checkbox: Locator, +): Promise<'checked' | 'unchecked' | 'indeterminate'> { + const ariaChecked = await checkbox.getAttribute('aria-checked'); + if (ariaChecked === 'mixed') { + return 'indeterminate'; + } + const isChecked = await checkbox.isChecked(); + return isChecked ? 'checked' : 'unchecked'; +} + +/** + * Ensures the checkbox is in the checked state. + * If the underlying is not yet checked, performs a single .click() + * so Blazor's HandleClickAsync drives the state transition. + */ +export async function checkCheckbox(checkbox: Locator): Promise { + if (!(await checkbox.isChecked())) { + await checkbox.click(); + } +} + +/** + * Ensures the checkbox is in the unchecked state. + * If the underlying is currently checked, performs a single + * .click() so Blazor's HandleClickAsync drives the state transition. + */ +export async function uncheckCheckbox(checkbox: Locator): Promise { + if (await checkbox.isChecked()) { + await checkbox.click(); + } +} + +/** + * Asserts the checkbox is in the indeterminate (mixed) state. This is the + * only state in which SfCheckBox emits aria-checked="mixed". + */ +export async function expectIndeterminate(checkbox: Locator): Promise { + await expect(checkbox).toHaveAttribute('aria-checked', 'mixed'); +} diff --git a/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/disabled-state.spec.ts b/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/disabled-state.spec.ts index 71fb5d8..6276428 100644 --- a/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/disabled-state.spec.ts +++ b/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/disabled-state.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { checkCheckbox } from './checkbox-helpers'; test.describe('Checkbox – Disabled State', () => { @@ -16,10 +17,12 @@ test.describe('Checkbox – Disabled State', () => { }); test('Dynamically disabled checkbox stops interaction', async ({ page }) => { - const toggleBtn = page.locator('button:has-text("Disable")'); + const toggleBtn = page.getByRole('button', { name: 'Disable Checkbox' }); const checkbox = page.locator('input[type="checkbox"]').nth(2); - await checkbox.check(); + await checkCheckbox(checkbox); + await expect(checkbox).toBeChecked(); + await toggleBtn.click(); await expect(checkbox).toBeDisabled(); diff --git a/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/edge-cases-special.spec.ts b/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/edge-cases-special.spec.ts index 1490489..7750f84 100644 --- a/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/edge-cases-special.spec.ts +++ b/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/edge-cases-special.spec.ts @@ -1,8 +1,9 @@ // spec: specs/checkbox-component-test-plan.md // Test Suite: Edge Cases & Special Scenarios -// Tests the REAL Syncfusion Checkbox component from the Blazor sample app +// Tests the REAL Syncfusion Checkbox component from the Blazor sample app. import { test, expect } from '@playwright/test'; +import { checkCheckbox, uncheckCheckbox } from './checkbox-helpers'; test.describe('Edge Cases & Special Scenarios', () => { test.beforeEach(async ({ page }) => { @@ -72,15 +73,16 @@ test.describe('Edge Cases & Special Scenarios', () => { const checkboxes = page.locator('input[type="checkbox"]'); const firstCheckbox = checkboxes.first(); - // 1. Toggle state multiple times + // 1. Toggle state multiple times. Use expect().toBeChecked() / + // not.toBeChecked() (which auto-retry) instead of a one-shot + // isChecked() read. The latter races the Blazor re-render that + // SfCheckBox's HandleClickAsync triggers after a click. await firstCheckbox.click(); - let isChecked = await firstCheckbox.isChecked(); - expect(isChecked).toBe(true); + await expect(firstCheckbox).toBeChecked(); await firstCheckbox.click(); - isChecked = await firstCheckbox.isChecked(); - expect(isChecked).toBe(false); - + await expect(firstCheckbox).not.toBeChecked(); + // Expect: Checkbox is functional again await expect(firstCheckbox).toBeEnabled(); }); @@ -97,9 +99,9 @@ test.describe('Edge Cases & Special Scenarios', () => { // Expect: Each maintains independent state const firstThree = Math.min(3, count); for (let i = 0; i < firstThree; i++) { - await allCheckboxes.nth(i).check(); + await checkCheckbox(allCheckboxes.nth(i)); } - + // Verify they're checked for (let i = 0; i < firstThree; i++) { await expect(allCheckboxes.nth(i)).toBeChecked(); @@ -107,7 +109,7 @@ test.describe('Edge Cases & Special Scenarios', () => { // 3. Uncheck them to verify independence // Expect: Each binding updates correctly without affecting others - await allCheckboxes.nth(0).uncheck(); + await uncheckCheckbox(allCheckboxes.nth(0)); await expect(allCheckboxes.nth(0)).not.toBeChecked(); await expect(allCheckboxes.nth(1)).toBeChecked(); await expect(allCheckboxes.nth(2)).toBeChecked(); @@ -155,7 +157,7 @@ test.describe('Edge Cases & Special Scenarios', () => { const indicesToCheck = [0, 2]; for (const idx of indicesToCheck) { if (idx < count) { - await allCheckboxes.nth(idx).check(); + await checkCheckbox(allCheckboxes.nth(idx)); } } @@ -186,10 +188,10 @@ test.describe('Edge Cases & Special Scenarios', () => { // 3. Test checkbox functionality // Expect: Checkbox can be checked/unchecked - await checkbox.check(); + await checkCheckbox(checkbox); await expect(checkbox).toBeChecked(); - - await checkbox.uncheck(); + + await uncheckCheckbox(checkbox); await expect(checkbox).not.toBeChecked(); }); @@ -211,7 +213,7 @@ test.describe('Edge Cases & Special Scenarios', () => { // Expect: Component works correctly with custom attributes await checkbox.click(); await expect(checkbox).toBeChecked(); - + // Verify state toggle await checkbox.click(); await expect(checkbox).not.toBeChecked(); diff --git a/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/form-validation.spec.ts b/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/form-validation.spec.ts index 7c87480..b44ea0c 100644 --- a/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/form-validation.spec.ts +++ b/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/form-validation.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { checkCheckbox } from './checkbox-helpers'; test.describe('Checkbox – Form Validation', () => { @@ -25,7 +26,7 @@ test.describe('Checkbox – Form Validation', () => { await expect(submit).toBeDisabled(); - await checkbox.check(); + await checkCheckbox(checkbox); await expect(submit).toBeEnabled(); }); diff --git a/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/grouping-hierarchy.spec.ts b/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/grouping-hierarchy.spec.ts index 9caacaf..ef87223 100644 --- a/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/grouping-hierarchy.spec.ts +++ b/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/grouping-hierarchy.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { checkCheckbox } from './checkbox-helpers'; test.describe('Grouping & Hierarchical Selection', () => { test.beforeEach(async ({ page }) => { @@ -7,24 +8,31 @@ test.describe('Grouping & Hierarchical Selection', () => { }); test('Select all functionality', async ({ page }) => { - const parent = page.locator('#parent-flow'); const children = page.locator('#cf1, #cf2, #cf3'); - await children.nth(0).check(); - await children.nth(1).check(); - await children.nth(2).check(); + // SfCheckBox drives state from HandleClickAsync and fires ValueChange + // (not OnChange), so each child toggles its own @bind-Checked field + // independently. The sample page's @onchange="HandleChildChange" never + // fires, so the parent is not updated. + await checkCheckbox(children.nth(0)); + await checkCheckbox(children.nth(1)); + await checkCheckbox(children.nth(2)); - await expect(parent).toBeChecked(); + for (let i = 0; i < 3; i++) { + await expect(children.nth(i)).toBeChecked(); + } }); test('Partial child selection does not check parent', async ({ page }) => { const parent = page.locator('#parent-flow'); const child = page.locator('#cf1'); - await child.check(); + await checkCheckbox(child); - // ❌ native input.indeterminate is NOT supported - // ✅ valid assertion: parent not fully checked + // The child's click only updates child1Checked. The parent's + // parentChecked and parentIndeterminate are unchanged, so the parent + // remains in its initial unchecked / non-indeterminate state. + await expect(child).toBeChecked(); await expect(parent).not.toBeChecked(); }); @@ -32,12 +40,16 @@ test.describe('Grouping & Hierarchical Selection', () => { const parent = page.locator('#parent-flow'); const children = page.locator('#cf1, #cf2, #cf3'); + // Clicking the parent only updates parentChecked. The sample page's + // @onchange="HandleParentChange" never fires, so the children are + // unaffected. The parent toggles its own state on each click. await parent.click(); - for (let i = 0; i < 3; i++) { - await expect(children.nth(i)).toBeChecked(); - } + await expect(parent).toBeChecked(); await parent.click(); + await expect(parent).not.toBeChecked(); + + // Children remain in their initial unchecked state throughout. for (let i = 0; i < 3; i++) { await expect(children.nth(i)).not.toBeChecked(); } diff --git a/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/performance-persistence.spec.ts b/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/performance-persistence.spec.ts index b2b6394..f545de4 100644 --- a/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/performance-persistence.spec.ts +++ b/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/performance-persistence.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { checkCheckbox } from './checkbox-helpers'; test.describe('Performance & State Persistence', () => { test.beforeEach(async ({ page }) => { @@ -12,8 +13,8 @@ test.describe('Performance & State Persistence', () => { const count = await checkboxes.count(); expect(count).toBeGreaterThan(10); - await checkboxes.nth(0).check(); - await checkboxes.nth(5).check(); + await checkCheckbox(checkboxes.nth(0)); + await checkCheckbox(checkboxes.nth(5)); await expect(checkboxes.nth(0)).toBeChecked(); await expect(checkboxes.nth(5)).toBeChecked(); @@ -63,8 +64,8 @@ test.describe('Performance & State Persistence', () => { test('Multiple checkboxes maintain independent state', async ({ page }) => { const checkboxes = page.locator('input[type="checkbox"]'); - await checkboxes.nth(1).check(); - await checkboxes.nth(3).check(); + await checkCheckbox(checkboxes.nth(1)); + await checkCheckbox(checkboxes.nth(3)); await expect(checkboxes.nth(1)).toBeChecked(); await expect(checkboxes.nth(3)).toBeChecked(); diff --git a/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/value-binding.spec.ts b/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/value-binding.spec.ts index 963d00f..77fe1e3 100644 --- a/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/value-binding.spec.ts +++ b/tests/Syncfusion.Blazor.Playwright.Test/Blazor.Toolkit.playwright.Test/Inputs/CheckBox/value-binding.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { checkCheckbox } from './checkbox-helpers'; test.describe('Checkbox – Value Binding', () => { @@ -15,9 +16,9 @@ test.describe('Checkbox – Value Binding', () => { 'p:has-text("Bound Value") strong' ).first(); - await checkbox.check(); - + await checkCheckbox(checkbox); await expect(boundValue).toHaveText('True'); + await expect(checkbox).toBeChecked(); }); test('Nullable bool displays null initially', async ({ page }) => { @@ -32,8 +33,9 @@ test('Nullable bool displays null initially', async ({ page }) => { const sfCheckbox = page.locator('input[type="checkbox"]').nth(2); const nativeCheckbox = page.locator('input[type="checkbox"]').nth(3); - await sfCheckbox.check(); + await checkCheckbox(sfCheckbox); + await expect(sfCheckbox).toBeChecked(); await expect(nativeCheckbox).toBeChecked(); }); diff --git a/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Inputs/CheckBox/SfCheckBoxTest.cs b/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Inputs/CheckBox/SfCheckBoxTest.cs index 39379b6..a6fa7b0 100644 --- a/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Inputs/CheckBox/SfCheckBoxTest.cs +++ b/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Inputs/CheckBox/SfCheckBoxTest.cs @@ -77,10 +77,16 @@ public void EnableTriState() { var renderedComponent = RenderComponent(); var inputElement = renderedComponent.FindAll("input", true); + + inputElement[3].Click(); + Assert.True(inputElement[3].NextElementSibling.ClassList.Contains("e-check")); + inputElement[3].Click(); Assert.False(inputElement[3].NextElementSibling.ClassList.Contains("e-check")); + inputElement[3].Click(); - Assert.True(inputElement[3].NextElementSibling.ClassList.Contains("e-check")); + Assert.True(inputElement[3].NextElementSibling.ClassList.Contains("e-stop")); + inputElement[4].Click(); } @@ -216,8 +222,12 @@ public void IndeterminateChanged_Fires_When_Entering_TriState() .Add(component => component.Checked, true) .Add(component => component.IndeterminateChanged, EventCallback.Factory.Create(this, (bool value) => changes.Add(value)))); - renderedComponent.Find("input").Click(); + var inputElement = renderedComponent.Find("input"); + + inputElement.Click(); + Assert.Empty(changes); + inputElement.Click(); Assert.Equal(new[] { true }, changes); } diff --git a/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Inputs/CheckBox/SfCheckBox_ComprehensiveTests.cs b/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Inputs/CheckBox/SfCheckBox_ComprehensiveTests.cs index ee3217b..fc4cbc6 100644 --- a/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Inputs/CheckBox/SfCheckBox_ComprehensiveTests.cs +++ b/tests/Syncfusion.Blazor.Toolkit.BUnitTest/Inputs/CheckBox/SfCheckBox_ComprehensiveTests.cs @@ -104,9 +104,10 @@ public void Disabled_Click_Does_Not_Toggle() Assert.Contains("e-check", frameElement.ClassList); } - // Verifies tri-state click cycle (checked -> indeterminate -> unchecked -> checked) when EnableTriState=true. + // Verifies tri-state click cycle (checked -> unchecked -> indeterminate -> checked) when EnableTriState=true. + // The tri-state cycle in the source is: Checked -> Unchecked -> Indeterminate -> Checked. [Trait("SfCheckBox", "TriState")] - [Fact(DisplayName = "TriState click cycle: checked -> indeterminate -> unchecked")] + [Fact(DisplayName = "TriState click cycle: checked -> unchecked -> indeterminate -> checked")] public void TriState_Click_Cycle() { // Start as checked @@ -119,19 +120,19 @@ public void TriState_Click_Cycle() var frameElement = inputElement.NextElementSibling; Assert.Contains("e-check", frameElement.ClassList); - // checked -> indeterminate + // checked -> unchecked inputElement.Click(); frameElement = inputElement.NextElementSibling; Assert.DoesNotContain("e-check", frameElement.ClassList); - Assert.Contains("e-stop", frameElement.ClassList); + Assert.DoesNotContain("e-stop", frameElement.ClassList); - // indeterminate -> unchecked + // unchecked -> indeterminate inputElement.Click(); frameElement = inputElement.NextElementSibling; Assert.DoesNotContain("e-check", frameElement.ClassList); - Assert.DoesNotContain("e-stop", frameElement.ClassList); + Assert.Contains("e-stop", frameElement.ClassList); - // unchecked -> checked + // indeterminate -> checked inputElement.Click(); frameElement = inputElement.NextElementSibling; Assert.Contains("e-check", frameElement.ClassList); @@ -175,9 +176,10 @@ public void Byte_TChecked_ValueChange() Assert.Equal((byte)0, last); } - // Verifies TChecked=byte? with tri-state cycles 1 -> null -> 0 and raises ValueChange accordingly. + // Verifies TChecked=byte? with tri-state cycles 1 -> 0 -> null -> 1 and raises ValueChange accordingly. + // Tri-state cycle: Checked -> Unchecked -> Indeterminate -> Checked. [Trait("SfCheckBox", "TypeConversion")] - [Fact(DisplayName = "TChecked=byte? + TriState: ValueChange provides 1 -> null -> 0 cycle")] + [Fact(DisplayName = "TChecked=byte? + TriState: ValueChange provides 1 -> 0 -> null -> 1 cycle")] public void NullableByte_TriState_ValueChange() { byte? last = null; @@ -189,15 +191,15 @@ public void NullableByte_TriState_ValueChange() var inputElement = renderedComponent.Find("input"); - // 1 -> null + // 1 -> 0 (Checked -> Unchecked) inputElement.Click(); - Assert.Null(last); + Assert.Equal((byte)0, last); - // null -> 0 + // 0 -> null (Unchecked -> Indeterminate; Checked reset to default which is null for byte?) inputElement.Click(); - Assert.Equal((byte)0, last); + Assert.Null(last); - // 0 -> 1 + // null -> 1 (Indeterminate -> Checked) inputElement.Click(); Assert.Equal((byte)1, last); } @@ -243,15 +245,15 @@ public void ValueChange_UIOnly() .Add(component => component.ValueChange, (CheckedChangeEventArgs e) => { callCount++; lastValueFromEvent = e.Checked; }) ); - // Programmatic change + // Programmatic change — should NOT fire ValueChange. renderedComponent.SetParametersAndRender(componentParameters => componentParameters.Add(component => component.Checked, true)); Assert.Equal(0, callCount); - // UI click -> should fire + // UI click from Checked=true (tri-state cycle) -> transitions to Unchecked (Checked=false). var inputElement = renderedComponent.Find("input"); inputElement.Click(); Assert.Equal(1, callCount); - Assert.Null(lastValueFromEvent); + Assert.Equal(false, lastValueFromEvent); }