Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,24 +1,19 @@
<template>

<VContainer
fluid
class="pa-0 pb-5"
>
<LoadingText
v-if="loading"
class="pt-4"
<div class="selection-list">
<StudioLargeLoader
v-if="show(loaderKey, loading, 400)"
class="selection-loader"
/>
<template v-else>
<VTextField
<KTextbox
v-model="search"
style="max-width: 350px"
class="mt-4"
box
class="search-input"
:label="$tr('searchText')"
/>
<p
v-if="!listChannels.length"
class="grey--text mb-0 mt-4"
class="no-channels-found"
>
{{ $tr('noChannelsFound') }}
</p>
Expand All @@ -28,41 +23,39 @@
:key="channel.id"
flat
hover
class="list-card-hover px-3"
class="list-card-hover selection-card"
>
<VLayout
align-center
row
>
<Checkbox
v-model="selectedChannels"
color="primary"
<div class="selection-row">
Comment thread
Swoyamjeetcodes marked this conversation as resolved.
<KCheckbox
:checked="selectedChannels.includes(channel.id)"
:label="channel.name"
:showLabel="false"
:data-testid="`checkbox-${channel.id}`"
:value="channel.id"
class="channel ma-0"
class="channel-checkbox"
@change="handleSelectChannel(channel.id)"
/>

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.

KCheckbox has no accessible name — screen readers will announce it as a plain "checkbox" with no context about which channel it refers to. Since the card content is the visual label, a visible label prop isn't the right fix here. Instead:

<KCheckbox
    :aria-label="channel.name"
    :aria-description="channel.description"
    ...
  />

:aria-label gives the checkbox its accessible name; :aria-description optionally adds the channel description as supplementary context without any visible UI change.

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.

Turns out we needed to set the label and then hide it;

<KCheckbox
    :label="channel.name"
    :showLabel="false"
    ...
  />

<ChannelItem
:channelId="channel.id"
:data-testid="`channel-item-${channel.id}`"
@click="handleSelectChannel"
/>
</VLayout>
</div>
</VCard>
</template>
</template>
</VContainer>
</div>

</template>


<script>

import sortBy from 'lodash/sortBy';
import useKShow from 'kolibri-design-system/lib/composables/useKShow';
import { mapGetters, mapActions } from 'vuex';
import ChannelItem from './ChannelItem';
import { ChannelListTypes } from 'shared/constants';
import Checkbox from 'shared/views/form/Checkbox';
import LoadingText from 'shared/views/LoadingText';
import StudioLargeLoader from 'shared/views/StudioLargeLoader';

function listTypeValidator(value) {
// The value must match one of the ListTypes
Expand All @@ -72,9 +65,12 @@
export default {
name: 'ChannelSelectionList',
components: {
Checkbox,
ChannelItem,
LoadingText,
StudioLargeLoader,
},
setup() {
const { show } = useKShow();
return { show };
},
props: {
value: {
Expand Down Expand Up @@ -117,6 +113,9 @@
'name',
);
},
loaderKey() {
return `channel-selection-list-${this.listType}`;
},
},
mounted() {
this.loading = true;
Expand Down Expand Up @@ -146,12 +145,37 @@

<style lang="scss" scoped>

.add-channel-button {
margin: 0;
.selection-list {
padding-bottom: 20px;
}

.selection-loader {
padding-top: 16px;
}

.search-input {
max-width: 350px;
margin-top: 16px;
}

.channel /deep/ .k-checkbox {
vertical-align: middle;
.no-channels-found {
margin-top: 16px;
margin-bottom: 0;
color: v-bind('$themeTokens.annotation');
Comment thread
Swoyamjeetcodes marked this conversation as resolved.
}

.selection-card {
padding-inline: 12px;
}

.selection-row {
display: flex;
align-items: center;
}

.channel-checkbox {
padding-inline-end: 4px;
margin: 0;
}

.list-card-hover {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,27 @@ import { Store } from 'vuex';
import ChannelSelectionList from '../ChannelSelectionList';
import { ChannelListTypes } from 'shared/constants';

jest.mock('kolibri-design-system/lib/composables/useKShow', () => ({
__esModule: true,
default: () => ({
show: (_id, loading) => loading,
Comment thread
Swoyamjeetcodes marked this conversation as resolved.
}),
}));

const searchWord = 'search test';

const editChannel = {
id: 'editchannel',
name: searchWord,
description: '',
description: 'A curated collection of math resources',
edit: true,
published: true,
};

const editChannel2 = {
id: 'editchannel2',
name: 'Another Channel',
description: '',
description: 'Science and nature topics for all ages',
edit: true,
published: true,
};
Expand Down Expand Up @@ -79,33 +86,48 @@ describe('ChannelSelectionList', () => {
// Specific wait avoids wrapping the whole block in waitFor
expect(await screen.findByLabelText('Search for a channel')).toBeInTheDocument();

expect(screen.getByText(editChannel.name)).toBeInTheDocument();
expect(screen.getByText(editChannel2.name)).toBeInTheDocument();
expect(screen.queryByText(publicChannel.name)).not.toBeInTheDocument();
expect(screen.getByRole('heading', { name: editChannel.name })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: editChannel2.name })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: publicChannel.name })).not.toBeInTheDocument();
});

it('shows loader while the channel list is loading', async () => {
let resolveLoad;
const loadingPromise = new Promise(resolve => {
resolveLoad = resolve;
});
mockActions.loadChannelList.mockReturnValueOnce(loadingPromise);

await renderComponent();

expect(screen.getByTestId('loader')).toBeInTheDocument();

resolveLoad();
expect(await screen.findByLabelText('Search for a channel')).toBeInTheDocument();
});

it('filters the channel list when the user types in the search box', async () => {
const user = userEvent.setup();
await renderComponent();

// Wait for data load
expect(await screen.findByText(editChannel.name)).toBeInTheDocument();
expect(screen.getByText(editChannel2.name)).toBeInTheDocument();
expect(await screen.findByRole('heading', { name: editChannel.name })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: editChannel2.name })).toBeInTheDocument();

const searchInput = screen.getByLabelText('Search for a channel');
await user.clear(searchInput);
await user.type(searchInput, editChannel.name);

// Verify filter happened
expect(await screen.findByText(editChannel.name)).toBeInTheDocument();
expect(screen.queryByText(editChannel2.name)).not.toBeInTheDocument();
expect(await screen.findByRole('heading', { name: editChannel.name })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: editChannel2.name })).not.toBeInTheDocument();
});

it('selects a channel when the user clicks the checkbox', async () => {
const user = userEvent.setup();
const { emitted } = await renderComponent();

await screen.findByText(editChannel.name);
await screen.findByRole('heading', { name: editChannel.name });

// Using getByTestId because the component doesn't expose unique
// accessible roles for individual channel checkboxes
Expand All @@ -127,7 +149,7 @@ describe('ChannelSelectionList', () => {
// Initialize with the channel already selected
const { emitted } = await renderComponent({ value: [editChannel.id] });

await screen.findByText(editChannel.name);
await screen.findByRole('heading', { name: editChannel.name });

// Using getByTestId because the component doesn't expose unique
// accessible roles for individual channel checkboxes
Expand All @@ -146,7 +168,7 @@ describe('ChannelSelectionList', () => {
const user = userEvent.setup();
const { emitted } = await renderComponent();

await screen.findByText(editChannel.name);
await screen.findByRole('heading', { name: editChannel.name });

// Using getByTestId because the component doesn't expose accessible
// roles for channel cards
Expand All @@ -164,7 +186,7 @@ describe('ChannelSelectionList', () => {
// Initialize with the channel already selected
const { emitted } = await renderComponent({ value: [editChannel.id] });

await screen.findByText(editChannel.name);
await screen.findByRole('heading', { name: editChannel.name });

// Using getByTestId because the component doesn't expose accessible
// roles for channel cards
Expand All @@ -175,4 +197,13 @@ describe('ChannelSelectionList', () => {
expect(emitted().input).toHaveLength(1);
expect(emitted().input[0][0]).toEqual([]);
});

it('each checkbox has an accessible name matching its channel name', async () => {
await renderComponent();

// KCheckbox renders a visually-hidden <label for="id"> associated with the input,
// so getByRole resolves the accessible name correctly for screen readers
expect(await screen.findByRole('checkbox', { name: editChannel.name })).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: editChannel2.name })).toBeInTheDocument();
});
});
Loading