Skip to content

[KListbox] Add comprehensive Single-Select mode support#1293

Open
Prashant-thakur77 wants to merge 5 commits into
learningequality:release-v5from
Prashant-thakur77:feature/klistbox-single-select
Open

[KListbox] Add comprehensive Single-Select mode support#1293
Prashant-thakur77 wants to merge 5 commits into
learningequality:release-v5from
Prashant-thakur77:feature/klistbox-single-select

Conversation

@Prashant-thakur77

@Prashant-thakur77 Prashant-thakur77 commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Description

This PR updates KListbox to natively support strict single-selection. This is a foundational requirement for the upcoming KMultiSelect component, which needs to be able to operate in a single-select mode without checkboxes. It introduces a functional multiple="false" guard to the listbox and a showCheckbox="false" visual fallback to the options.

Screencast.From.2026-07-01.12-11-42.mp4

Issue addressed

Addresses #1292

Changelog

  • Description: Added native single-select support to KListbox and KListboxOption.
  • Products impact: none
  • Addresses: [KListbox] Add comprehensive Single-Select mode support #1292
  • Components: KListbox, KListboxOption
  • Breaking: no
  • Impacts a11y: yes
  • Guidance: Set multiple="false" on KListbox and showCheckbox="false" on KListboxOption to enforce single selection rules (disables Ctrl+A, replaces value instead of appending) while dropping the visual checkbox for plain text rendering with selection highlighting.

Steps to test

  1. Navigate to the KListbox component page in the local documentation.
  2. Scroll down to the new Single Select example.
  3. Verify that clicking options correctly replaces the active selection (rather than selecting multiple).
  4. Verify that the Ctrl+A "select all" keyboard shortcut is blocked.
  5. Verify that selected options highlight with the primary blue text and a subtle gray background.

(optional) Implementation notes

At a high level, how did you implement this?

  • Added multiple prop to KListbox. When false, clicking an option replaces the array and ignores Ctrl+A.
  • Added showCheckbox prop to KListboxOption. When false, the KCheckbox wrapper is removed and replaced with a plain <span class="k-listbox-option-label">.
  • Migrated inline styles to the <style> block and strictly maintained the 36px minimum row height and 24px line height to ensure absolute visual parity with the checkbox rows.

Testing checklist

  • Contributor has fully tested the PR manually
  • If there are any front-end changes, before/after screenshots are included
  • Critical and brittle code paths are covered by unit tests
  • The change is described in the changelog section above

Reviewer guidance

  • Is the code clean and well-commented?
  • Are there tests for this change?
  • Are all UI components LTR and RTL compliant (if applicable)?

Comments

Note to reviewers: This branch (feature/klistbox-single-select) was branched off of my previous PR for feature/kmultiselect-components-input-node. Therefore, the git diff currently shows the KMultiSelectInput and KMultiSelectNode files as well.

If my previous PR (#1289) gets merged first, I can rebase this branch to clean up the diff. Otherwise, you can focus your review on the KListbox and KListboxOption directories!

@learning-equality-bot

Copy link
Copy Markdown

👋 Hi @Prashant-thakur77, thanks for contributing!

For the review process to begin, please verify that the following is satisfied:

  • Contribution is aligned with our contributing guidelines

  • Pull request description has correctly filled AI usage section & follows our AI guidance:

    AI guidance

    State explicitly whether you didn't use or used AI & how.

    If you used it, ensure that the PR is aligned with Using AI as well as our DEEP framework. DEEP asks you:

    • Disclose — Be open about when you've used AI for support.
    • Engage critically — Question what is generated. Review code for correctness and unnecessary complexity.
    • Edit — Review and refine AI output. Remove unnecessary code and verify it still works after your edits.
    • Process sharing — Explain how you used the AI so others can learn.

    Examples of good disclosures:

    "I used Claude Code to implement the component, prompting it to follow the pattern in ComponentX. I reviewed the generated code, removed unnecessary error handling, and verified the tests pass."

    "I brainstormed the approach with Gemini, then had it write failing tests for the feature. After reviewing the tests, I used Claude Code to generate the implementation. I refactored the output to reduce verbosity and ran the full test suite."

Also check that issue requirements are satisfied & you ran pre-commit locally.

Pull requests that don't follow the guidelines will be closed.

Reviewer assignment can take up to 2 weeks.

@Prashant-thakur77 Prashant-thakur77 changed the title Feature/klistbox single select [KListbox] Add comprehensive Single-Select mode support Jun 26, 2026
@LianaHarris360 LianaHarris360 linked an issue Jun 29, 2026 that may be closed by this pull request
8 tasks
@Prashant-thakur77 Prashant-thakur77 force-pushed the feature/klistbox-single-select branch from cc81794 to 4fbbea8 Compare July 1, 2026 06:30
@Prashant-thakur77

Copy link
Copy Markdown
Contributor Author

I have rebased this branch with the latest updates on the release v-5 branch :)

@learning-equality-bot

Copy link
Copy Markdown

📢✨ Before we assign a reviewer, we'll turn on @rtibblesbot to pre-review. Its comments are generated by an LLM, and should be evaluated accordingly.

@rtibblesbot rtibblesbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implements all stated acceptance criteria from #1292 with focused, well-scoped changes and matching unit tests. One blocking accessibility issue in the new selected-option styling.

  • blocking: selected-option text/background combo fails WCAG AA contrast (inline comment).
  • Same root cause also bypasses the design-token pattern used elsewhere in the file (folded into the inline comment).

CI: mixed/pending at time of review — please confirm it's green before merge.

Manual QA (live docs-page session):

  • Click replaces selection instead of appending — pass
  • Already-selected option doesn't toggle off — pass
  • Ctrl+A is blocked in single-select mode — pass
  • Keyboard focus/navigation works, no focus trap — pass
  • RTL layout mirrors correctly — pass
  • Mobile viewport (375px) has no overflow/clipping — pass
  • Selected-option color contrast — fail, see inline comment

@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?

Ran a phased review pipeline over the pull request diff:

  • Classified the diff to select review passes (core, frontend, backend) and whether manual QA was required
  • Core review pass checked correctness, design, architecture, testing, completeness, and DRY/SRP/Rule-of-Three principles
  • Specialized frontend/backend review passes applied framework-specific lenses where those files changed
  • For UI changes: manual QA and an accessibility audit against a live dev server, when available
  • Checked CI status and linked issue acceptance criteria
  • Synthesized one review from those passes and chose the verdict from the findings, CI status, and QA evidence

const rowStyles = computed(() => ({
backgroundColor: themeTokens().surface,
backgroundColor:
!props.showCheckbox && isSelected.value ? 'rgba(0, 0, 0, 0.05)' : themeTokens().surface,

@rtibblesbot rtibblesbot Jul 2, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved — addressed in the current code.

blocking: This selected-state background, combined with the $themeTokens.primary text color set two lines up (line 35), fails WCAG AA contrast. Measured via axe-core on the live single-select example: foreground #4368f5 on background rgba(0,0,0,0.05) over white (≈#f2f2f2) = 4.13:1, below the 4.5:1 required for this 16px/500-weight text.

This also hardcodes a literal color instead of using the theme tokens already imported in this file (themePalette().grey.v_100 is used for :hover right below). Routing the selected-background through a verified-AA token pair (darker background and/or a less-saturated primary shade) would fix both the contrast failure and the literal-vs-token inconsistency in one change.

@Prashant-thakur77 Prashant-thakur77 Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rgba(0, 0, 0, 0.05) selected-state background is a direct replication of the existing KSelectOption pattern (KSelect/KSelectOption.vue).

&.is-selected {
font-weight: 500;
background-color: rgba(black, 0.05);
}

@LianaHarris360 should we change here as the bot suggest for the compliance with WCAG AA contrast?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — I checked KSelectOption.vue at that commit, and it has the same combination (color: this.$themeTokens.primary on .ui-select-option text, background-color: rgba(black, 0.05) on .is-selected), so this does appear to be a pre-existing contrast gap being carried over rather than something new to this PR. That said, it doesn't change the contrast math for this file — 4.13:1 is still below the 4.5:1 AA threshold here. Deferring to @LianaHarris360 on whether to fix it now in KListboxOption or track it separately alongside KSelectOption.

@Prashant-thakur77 Prashant-thakur77 Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently it is like:
image
I tried 2 implementation if we decided to change it here:
1)No background, primary text + bold
The selected state is now indicated by $themeTokens.primary text color + fontWeight: 500 on a plain themeTokens().surface background.
image
2)grey.v_100 tint + bold
Fixed by replacing rgba(0, 0, 0, 0.05) with the themePalette().grey.v_100 token,fontWeight: 500
Screenshot From 2026-07-02 20-49-54
In this we can increase font weigth if we need that:
image

@LianaHarris360 LianaHarris360 Jul 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked the single selection use case in Studio, and there is no background color when a value is selected:

Image

Looks like it only uses the $themeTokens.primary text color with the plain themeTokens().surface background to indicate what value is selected. As for the KSelectOption contrast gap, it should be changed also, but not in this PR. Instead, I can address it in a separate issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done:)
image

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified — confirmed against the current branch: the selected-state background was removed, so $themeTokens.primary text now sits on a plain themeTokens().surface (#FFFFFF) instead of the tinted rgba(0,0,0,0.05) background. That's a contrast ratio of ~4.63:1, which clears the 4.5:1 AA threshold (up from 4.13:1). This also resolves the literal-color concern since the background is now just the surface token. Withdrawing this as blocking.

@LianaHarris360 agreed that the same gap in KSelectOption is out of scope here and worth tracking separately.

Comment thread docs/pages/klistbox.vue Outdated
Comment thread lib/candidate/listbox/KListboxOption/index.vue
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Percy Visual Test Results

Percy Dashboard: View Detailed Report

Environment:

  • Node.js Version: 20.x
  • OS: Ubuntu-latest

Instructions for Reviewers:

  • Click on the Percy Dashboard link to view detailed visual diffs.
  • Review the visual changes highlighted in the report.
  • Approve or request changes based on the visual differences.

@AlexVelezLl AlexVelezLl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just some thoughts!

Comment on lines +64 to +72
onMounted(() => {
listbox.registerOption({ id: optionId, value: props.value });
if (process.env.NODE_ENV !== 'production' && props.showCheckbox && !listbox.multiple) {
// eslint-disable-next-line no-console
console.warn(
"[KListboxOption] 'showCheckbox' is true but the parent KListbox is in single-select mode ('multiple: false'). Set 'showCheckbox: false' to hide the checkbox.",
);
}
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wonder if we should have something like "showSelector" and switch between a checkbox and a radio button based on whether multiple is true or false. Is there already a use case for single select?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a couple of single selection use cases in Studio. For the LanguageDropdown and the CountryField in the usersTable, there is no visual selected state displayed.

LanguageDropdown usersTable

But do we need to prevent the checkbox from being displayed? In both Studio cases, showCheckbox and multiple can be set to false to achieve the same result, while still allowing it to be flexible for future implementations.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense 😅. It's only that if we have a future implementation with a radioButton, then we will need to add a showRadioButton prop to allow showing the radio button, because reusing the showCheckbox wouldn't apply (and then we would need to add another check for showRadioButton=true and multiple=true).

:style="{
color: isSelected ? $themeTokens.primary : 'inherit',
fontWeight: isSelected ? '500' : 'normal',
}"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if KDS should be less opinionated about what the style should look like, since we can use KListbox with multiple=false for layouts like this, where the "selected" state is just a different background color.

Image

A potential solution may be to use these styles by default and provide a prop to override them.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that I saw #1293 (comment). I wonder whether the default styles should be just $themeTokens.primary, or if we should use the gray-ish background KSelect uses as well? (since KSelect is already used in many places and this would make everything feel more consistent)

image

Ultra nitpicky, and I think this can be decided in the future 😅, but just flagging it.

@Prashant-thakur77 Prashant-thakur77 Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes,we did go with the same styles as used in Kselect before but it fails WCAG AA contrast .You can see this in this comment. And that is why we decided to use the single select selected option with a $themeTokens.primary only

Comment thread lib/candidate/listbox/KListbox/index.vue Outdated
setup() {
const classLabel = 'single-select-class';
const listboxId = 'single-select-listbox';
const selected = ref(['literature']);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, even if it's just a single select, should we still pass an array? Not saying it's wrong, although it may potentially be a bit counterintuitive.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the v-model value is always an array regardless of the multiple mode. This was intentional from my end as it keeps the API consistent so consumers don't need to handle different value types based on the mode.So i thought it was a valid trade-off but yes it is a bit counterintuitive.Should i update it to handle modes seperatively as it is done in Kselect array for multiple and object for single select?

@AlexVelezLl AlexVelezLl Jul 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really, just making sure it is a thoughtful decision and it is well documented :), would be nice to have thoughts from others too.

Another example of something kind of similar is the KCheckbox v-model, if we pass it an array, it adds the value to the array, but if not, it will just unset the checkbox value to the v-model (i.e., setting value to null directly to inputValue instead of [value]/[])

@rtibblesbot rtibblesbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 of 2 prior findings resolved; 1 still open (see below).

  • [blocking] lib/candidate/listbox/KListboxOption/index.vue:34-37 — selected-state background was removed rather than replaced, leaving an acceptance criterion unimplemented. See inline comment.

CI: pending.

Prior-finding status

RESOLVED — lib/candidate/listbox/KListboxOption/index.vue:32-40 — WCAG AA contrast failure on selected-state background (rgba(0,0,0,0.05)) (see new finding above)
RESOLVED — lib/candidate/listbox/KListbox/index.vue:300-302 — injected multiple flag not reactive — fixed via getter


@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?

Ran an automatic code-only delta review triggered by new commits on a previously reviewed PR:

  • Retrieved prior bot reviews via the GitHub API
  • Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
  • Only raised NEW findings for newly introduced code
  • Core review pass only — specialized frontend/backend lenses and manual QA run when a review is explicitly requested
  • Synthesized one review from the passes and chose the verdict from the findings, CI status, and QA evidence

:style="{ lineHeight: '24px' }"
:style="{
color: isSelected ? $themeTokens.primary : 'inherit',
fontWeight: isSelected ? '500' : 'normal',

@rtibblesbot rtibblesbot Jul 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved — addressed in the current code.

blocking:

  • Commit 48400dfd0 deleted the flagged rgba(0,0,0,0.05) background instead of replacing it — this block now only varies color/fontWeight, no background on selection.
  • Issue [KListbox] Add comprehensive Single-Select mode support #1292 requires "blue text and gray backgrounds when selected."
  • Suggest themePalette().grey.v_100 (already used for :hover below) as the selected-state background — same token, already AA-safe with primary text.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested grey.v_100 (#F5F5F5) actually makes the contrast worse, not better:
Primary #4368F5 on grey.v_100 #F5F5F5 → 4.25:1 which fails WCAG AA
Primary #4368F5 on white #FFFFFF → 4.63:1 passes WCAG AA
Adding the grey background drops us below the 4.5:1 threshold.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Withdrawing this. I re-checked the contrast math myself and your numbers are right — primary on grey.v_100 is ~4.25:1 (fails AA), primary on white is ~4.63:1 (passes) — so grey.v_100 isn't a viable fix either, it'd trade one AA failure for another.

More importantly, I shouldn't have re-raised this at all: this exact background-vs-no-background question was already settled earlier in this thread — I withdrew the original blocking finding once the background was removed, and @LianaHarris360 confirmed that matches the actual Studio usage (primary text only, no background). Citing the issue's literal "gray background" wording to reopen it was a mistake on my part given that prior resolution. Apologies for the noise.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also have a grey.v_50. Would that work better? 😅

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried grey.v_50 (#FAFAFA),it is close at 4.43:1 but still just below the 4.5:1 AA threshold.😅

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks both — that confirms neither grey.v_100 (4.25:1) nor grey.v_50 (4.43:1) clears the 4.5:1 AA threshold with primary text, so the no-background resolution already in place (matching actual Studio usage per @LianaHarris360) is the right call. Nothing further needed from me here.

Comment thread lib/candidate/listbox/KListbox/index.vue

@rtibblesbot rtibblesbot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 of 2 prior findings resolved; no new findings on the current diff.

CI: mostly green, Percy visual tests still WAITING. Manual QA was required but did not complete this round, so this is a COMMENT rather than an approval.

Prior-finding status

RESOLVED — lib/candidate/listbox/KListboxOption/index.vue:34-37 — selected-state background/contrast: code at this location is unchanged from the upstream/release-v5 baseline (no gray/rgba background either way) and has not regressed since it was marked resolved; out of scope for this PR's delta.
RESOLVED — lib/candidate/listbox/KListboxOption/index.vue:121-128 — showCheckbox prop docstring wording cleanup.
ACKNOWLEDGED — lib/candidate/listbox/KListbox/index.vue:300 — praise: get multiple() getter avoids snapshotting props.multiple at setup time.


@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?

Ran an automatic code-only delta review triggered by new commits on a previously reviewed PR:

  • Retrieved prior bot reviews via the GitHub API
  • Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
  • Only raised NEW findings for newly introduced code
  • Core review pass only — specialized frontend/backend lenses and manual QA run when a review is explicitly requested
  • Synthesized one review from the passes and chose the verdict from the findings, CI status, and QA evidence

@rtibblesbot

Copy link
Copy Markdown
Contributor

🔵 Review posted

Last updated: 2026-07-03 15:57 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[KListbox] Add comprehensive Single-Select mode support

4 participants