Skip to content

Commit f89363e

Browse files
committed
Implement PicturePasswordOption component
1 parent ea4edcf commit f89363e

3 files changed

Lines changed: 379 additions & 0 deletions

File tree

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
<template>
2+
3+
<div class="picture-password-option">
4+
<!--
5+
The label wraps the hidden checkbox and visible content, making the entire
6+
option region clickable/activatable while keeping a single focusable element.
7+
-->
8+
<label
9+
class="option-label"
10+
:class="[
11+
$computedClass(optionLabelStyles),
12+
$computedClass({
13+
':focus-within': $coreOutline,
14+
}),
15+
]"
16+
>
17+
<!--
18+
Native checkbox: visually hidden but keyboard-focusable.
19+
We do not set the `disabled` HTML attribute so the input keeps focus.
20+
`aria-disabled` communicates the disabled state to assistive technology.
21+
-->
22+
<input
23+
type="checkbox"
24+
class="visuallyhidden"
25+
:checked="isSelected"
26+
:aria-disabled="disabled ? 'true' : undefined"
27+
@click.prevent="handleClick"
28+
@keydown="handleKeydown"
29+
>
30+
31+
<div
32+
class="icon-container"
33+
:style="iconContainerStyle"
34+
>
35+
<span
36+
v-if="isSelected"
37+
class="badge"
38+
aria-hidden="true"
39+
data-testid="badge"
40+
:style="{ backgroundColor: $themeTokens.primary, color: $themeTokens.textInverted }"
41+
>
42+
{{ sequencePosition }}
43+
</span>
44+
45+
<KIcon
46+
class="option-icon"
47+
:icon="icon"
48+
/>
49+
50+
<span class="icon-label">
51+
{{ labelText }}
52+
</span>
53+
</div>
54+
</label>
55+
</div>
56+
57+
</template>
58+
59+
60+
<script>
61+
62+
import { computed } from 'vue';
63+
import { themeTokens, themePalette } from 'kolibri-design-system/lib/styles/theme';
64+
import { picturePasswordStrings } from 'kolibri-common/strings/picturePasswordStrings';
65+
66+
export default {
67+
name: 'PicturePasswordOption',
68+
69+
setup(props, { emit }) {
70+
const $themeTokens = themeTokens();
71+
const $themePalette = themePalette();
72+
73+
const isSelected = computed(() => props.sequencePosition !== null);
74+
75+
const optionLabelStyles = computed(() => {
76+
if (isSelected.value) {
77+
return {
78+
border: `4px solid ${$themeTokens.primary}`,
79+
backgroundColor: $themePalette.blue.v_100,
80+
cursor: 'pointer',
81+
color: $themeTokens.text,
82+
fontWeight: 600,
83+
};
84+
}
85+
const unSelectedStyles = {
86+
border: `2px solid ${$themeTokens.fineLine}`,
87+
backgroundColor: $themePalette.grey.v_100,
88+
color: $themeTokens.annotation,
89+
};
90+
91+
if (props.disabled) {
92+
return {
93+
...unSelectedStyles,
94+
cursor: 'not-allowed',
95+
};
96+
}
97+
return {
98+
...unSelectedStyles,
99+
cursor: 'pointer',
100+
':hover': {
101+
borderColor: $themeTokens.primary,
102+
backgroundColor: $themePalette.blue.v_100,
103+
},
104+
};
105+
});
106+
107+
const labelText = computed(() => {
108+
const getter = picturePasswordStrings[`${props.iconName}$`];
109+
return getter ? getter() : props.iconName;
110+
});
111+
112+
const iconContainerStyle = computed(() => {
113+
if (!props.disabled) return {};
114+
return { filter: 'grayscale(100%)', opacity: '0.4' };
115+
});
116+
117+
const onSelect = () => {
118+
if (props.disabled) {
119+
emit('disabledSelect');
120+
} else {
121+
emit('select', props.icon);
122+
}
123+
};
124+
const handleClick = e => {
125+
e.preventDefault();
126+
onSelect();
127+
};
128+
129+
const handleKeydown = e => {
130+
if (e.key === 'Enter') {
131+
e.preventDefault();
132+
onSelect();
133+
}
134+
};
135+
136+
return {
137+
isSelected,
138+
optionLabelStyles,
139+
labelText,
140+
iconContainerStyle,
141+
handleClick,
142+
handleKeydown,
143+
};
144+
},
145+
146+
props: {
147+
/**
148+
* Resolved KIcon token to display (e.g. "birdColorful" or "birdStandard").
149+
*/
150+
icon: {
151+
type: String,
152+
required: true,
153+
},
154+
/**
155+
* Icon object name (e.g. "bird") used to look up the translated label.
156+
*/
157+
iconName: {
158+
type: String,
159+
required: true,
160+
},
161+
/**
162+
* position in the selection sequence when this option is selected
163+
* or null when unselected.
164+
*/
165+
sequencePosition: {
166+
type: Number,
167+
default: null,
168+
validator: value => value === null || [1, 2, 3].includes(value),
169+
},
170+
disabled: {
171+
type: Boolean,
172+
default: false,
173+
},
174+
},
175+
};
176+
177+
</script>
178+
179+
180+
<style lang="scss" scoped>
181+
182+
@import '~kolibri-design-system/lib/styles/definitions';
183+
184+
$badge-size: 32px;
185+
$selected-border-width: 4px;
186+
187+
.picture-password-option {
188+
display: inline-flex;
189+
min-width: 44px;
190+
min-height: 44px;
191+
}
192+
193+
/* The label is the interactive surface */
194+
.option-label {
195+
position: relative;
196+
display: flex;
197+
flex: 1;
198+
align-items: center;
199+
justify-content: center;
200+
padding: 12px;
201+
border-radius: 8px;
202+
transition:
203+
border-color $core-time,
204+
background-color $core-time;
205+
}
206+
207+
.icon-container {
208+
display: flex;
209+
flex-direction: column;
210+
gap: 4px;
211+
align-items: center;
212+
justify-content: center;
213+
}
214+
215+
.badge {
216+
position: absolute;
217+
// Position the badge centered on the top-left corner of the option, just outside the border.
218+
top: #{-1 * ($badge-size / 2 + $selected-border-width / 2)};
219+
left: #{-1 * ($badge-size / 2 + $selected-border-width / 2)};
220+
display: flex;
221+
align-items: center;
222+
justify-content: center;
223+
width: $badge-size;
224+
height: $badge-size;
225+
font-size: 16px;
226+
font-weight: bold;
227+
border-radius: 50%;
228+
}
229+
230+
.option-icon {
231+
width: 52px;
232+
height: 52px;
233+
}
234+
235+
.icon-label {
236+
font-size: 14px;
237+
text-align: center;
238+
}
239+
240+
</style>
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { render, screen } from '@testing-library/vue';
2+
import userEvent from '@testing-library/user-event';
3+
import '@testing-library/jest-dom';
4+
import { picturePasswordStrings } from 'kolibri-common/strings/picturePasswordStrings';
5+
import PicturePasswordOption from '../PicturePasswordOption.vue';
6+
7+
function renderComponent(props = {}) {
8+
return render(PicturePasswordOption, {
9+
props: {
10+
icon: 'birdColorful',
11+
iconName: 'bird',
12+
...props,
13+
},
14+
});
15+
}
16+
17+
describe('PicturePasswordOption', () => {
18+
describe('select event', () => {
19+
it('emits select when clicked and not disabled', async () => {
20+
const { emitted } = renderComponent();
21+
22+
await userEvent.click(screen.getByRole('checkbox', { name: picturePasswordStrings.bird$() }));
23+
24+
expect(emitted()['select']).toBeTruthy();
25+
expect(emitted()['select'][0]).toEqual(['birdColorful']);
26+
});
27+
28+
it('does not emit select when disabled', async () => {
29+
const { emitted } = renderComponent({ disabled: true });
30+
31+
await userEvent.click(screen.getByRole('checkbox', { name: picturePasswordStrings.bird$() }));
32+
33+
expect(emitted()['select']).toBeFalsy();
34+
});
35+
36+
it('emits disabledSelect instead of select when disabled', async () => {
37+
const { emitted } = renderComponent({ disabled: true });
38+
39+
await userEvent.click(screen.getByRole('checkbox', { name: picturePasswordStrings.bird$() }));
40+
41+
expect(emitted()['disabledSelect']).toBeTruthy();
42+
});
43+
});
44+
45+
describe('numbered badge', () => {
46+
it('renders the badge with the sequence position when selected', () => {
47+
renderComponent({ sequencePosition: 2 });
48+
49+
const badge = screen.getByTestId('badge');
50+
expect(badge).toBeInTheDocument();
51+
expect(badge.textContent.trim()).toBe('2');
52+
});
53+
54+
it('does not render the badge when sequencePosition is null', () => {
55+
renderComponent({ sequencePosition: null });
56+
57+
expect(screen.queryByTestId('badge')).not.toBeInTheDocument();
58+
});
59+
});
60+
61+
describe('accessibility', () => {
62+
it('sets aria-disabled when disabled', () => {
63+
renderComponent({ disabled: true });
64+
65+
expect(
66+
screen.getByRole('checkbox', { name: picturePasswordStrings.bird$() }),
67+
).toHaveAttribute('aria-disabled', 'true');
68+
});
69+
70+
it('does not set aria-disabled when not disabled', () => {
71+
renderComponent({ disabled: false });
72+
73+
expect(
74+
screen.getByRole('checkbox', { name: picturePasswordStrings.bird$() }),
75+
).not.toHaveAttribute('aria-disabled');
76+
});
77+
78+
it('uses the translated object name as the checkbox accessible label', () => {
79+
renderComponent({ iconName: 'bird' });
80+
81+
expect(
82+
screen.getByRole('checkbox', { name: picturePasswordStrings.bird$() }),
83+
).toBeInTheDocument();
84+
});
85+
});
86+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { createTranslator } from 'kolibri/utils/i18n';
2+
3+
export const picturePasswordStrings = createTranslator('PicturePasswordStrings', {
4+
tree: {
5+
message: 'Tree',
6+
context: 'Label for the tree icon used in picture password selection.',
7+
},
8+
moon: {
9+
message: 'Moon',
10+
context: 'Label for the moon icon used in picture password selection.',
11+
},
12+
bee: {
13+
message: 'Bee',
14+
context: 'Label for the bee icon used in picture password selection.',
15+
},
16+
star: {
17+
message: 'Star',
18+
context: 'Label for the star icon used in picture password selection.',
19+
},
20+
21+
leaf: {
22+
message: 'Leaf',
23+
context: 'Label for the leaf icon used in picture password selection.',
24+
},
25+
mouse: {
26+
message: 'Mouse',
27+
context: 'Label for the mouse icon used in picture password selection.',
28+
},
29+
water: {
30+
message: 'Water',
31+
context: 'Label for the water icon used in picture password selection.',
32+
},
33+
fish: {
34+
message: 'Fish',
35+
context: 'Label for the fish icon used in picture password selection.',
36+
},
37+
dog: {
38+
message: 'Dog',
39+
context: 'Label for the dog icon used in picture password selection.',
40+
},
41+
smile: {
42+
message: 'Smile',
43+
context: 'Label for the smile icon used in picture password selection.',
44+
},
45+
flower: {
46+
message: 'Flower',
47+
context: 'Label for the flower icon used in picture password selection.',
48+
},
49+
bird: {
50+
message: 'Bird',
51+
context: 'Label for the bird icon used in picture password selection.',
52+
},
53+
});

0 commit comments

Comments
 (0)