diff --git a/src/examples/src/config.tsx b/src/examples/src/config.tsx index 62ff93e6a7..9022a80adb 100644 --- a/src/examples/src/config.tsx +++ b/src/examples/src/config.tsx @@ -110,6 +110,7 @@ import ListItemRenderer from './widgets/list/ListItemRenderer'; import FetchedResource from './widgets/list/FetchedResource'; import DisabledList from './widgets/list/Disabled'; import DraggableList from './widgets/list/Draggable'; +import StaticOptionList from './widgets/list/StaticOption'; import CustomThemeList from './widgets/list/CustomTheme'; import Menu from './widgets/list/Menu'; import CustomTransformer from './widgets/list/CustomTransformer'; @@ -161,6 +162,7 @@ import RequiredRangeSlider from './widgets/range-slider/Required'; import LabelledRangeSlider from './widgets/range-slider/Labelled'; import ControlledRangeSlider from './widgets/range-slider/Controlled'; import AdditionalText from './widgets/select/AdditionalText'; +import PlaceholderSelect from './widgets/select/Placeholder'; import BasicSelect from './widgets/select/Basic'; import ControlledSelect from './widgets/select/Controlled'; import CustomRenderer from './widgets/select/CustomRenderer'; @@ -254,6 +256,7 @@ import PaginationPageSizeSelector from './widgets/pagination/PageSizeSelector'; import PaginationControlled from './widgets/pagination/Controlled'; import PaginationSiblingCount from './widgets/pagination/SiblingCount'; import BasicTypeahead from './widgets/typeahead/Basic'; +import PlaceholderTypeahead from './widgets/typeahead/Placeholder'; import RemoteTypeahead from './widgets/typeahead/RemoteSource'; import ValidatedTypeahead from './widgets/typeahead/Validation'; import FreeTextTypeahead from './widgets/typeahead/FreeText'; @@ -1147,6 +1150,11 @@ export const config = { module: FillList, title: 'Fill' }, + { + filename: 'StaticOption', + module: StaticOptionList, + title: 'Static Option' + }, { description: 'This example shows how list items can be easily themed', filename: 'CustomTheme', @@ -1574,6 +1582,13 @@ export const config = { sandbox: true, size: 'medium' }, + { + filename: 'Placeholder', + module: PlaceholderSelect, + title: 'Placeholder', + sandbox: true, + size: 'medium' + }, { filename: 'DisabledSelect', module: DisabledSelect, @@ -2090,6 +2105,11 @@ export const config = { sandbox: true, size: 'large' }, + { + filename: 'Placeholder', + module: PlaceholderTypeahead, + title: 'Placeholder' + }, { filename: 'Controlled', module: ControlledTypeahead, diff --git a/src/examples/src/widgets/list/StaticOption.tsx b/src/examples/src/widgets/list/StaticOption.tsx new file mode 100644 index 0000000000..fc4375681b --- /dev/null +++ b/src/examples/src/widgets/list/StaticOption.tsx @@ -0,0 +1,22 @@ +import { create, tsx } from '@dojo/framework/core/vdom'; +import List from '@dojo/widgets/list'; +import icache from '@dojo/framework/core/middleware/icache'; +import Example from '../../Example'; +import { listOptionTemplate } from '../../template'; + +const factory = create({ icache }); + +export default factory(function StaticOption({ middleware: { icache } }) { + return ( + + { + icache.set('value', value); + }} + staticOption={{ value: 'static', label: 'This is a static option' }} + /> + {`Clicked on: ${JSON.stringify(icache.getOrSet('value', ''))}`} + + ); +}); diff --git a/src/examples/src/widgets/select/AdditionalText.tsx b/src/examples/src/widgets/select/AdditionalText.tsx index 79e59eebf4..7ed6197146 100644 --- a/src/examples/src/widgets/select/AdditionalText.tsx +++ b/src/examples/src/widgets/select/AdditionalText.tsx @@ -1,12 +1,12 @@ -import { create, tsx } from '@dojo/framework/core/vdom'; -import Select from '@dojo/widgets/select'; import icache from '@dojo/framework/core/middleware/icache'; -import Example from '../../Example'; import { - createResourceTemplate, - createResourceMiddleware + createResourceMiddleware, + createResourceTemplate } from '@dojo/framework/core/middleware/resources'; +import { create, tsx } from '@dojo/framework/core/vdom'; import { ListOption } from '@dojo/widgets/list'; +import Select from '@dojo/widgets/select'; +import Example from '../../Example'; const resource = createResourceMiddleware(); const factory = create({ icache, resource }); @@ -27,7 +27,6 @@ export default factory(function AdditionalText({ id, middleware: { icache, resou icache.set('value', value); }} helperText="I am the helper text" - placeholder="I am a placeholder" > {{ label: 'Additional Text' diff --git a/src/examples/src/widgets/select/Placeholder.tsx b/src/examples/src/widgets/select/Placeholder.tsx new file mode 100644 index 0000000000..0bdd77bf1c --- /dev/null +++ b/src/examples/src/widgets/select/Placeholder.tsx @@ -0,0 +1,41 @@ +import icache from '@dojo/framework/core/middleware/icache'; +import { + createResourceMiddleware, + createResourceTemplate +} from '@dojo/framework/core/middleware/resources'; +import { create, tsx } from '@dojo/framework/core/vdom'; +import { ListOption } from '@dojo/widgets/list'; +import Select from '@dojo/widgets/select'; +import Example from '../../Example'; + +const resource = createResourceMiddleware(); +const factory = create({ icache, resource }); +const options = [ + { value: '1', label: 'cat' }, + { value: '2', label: 'dog' }, + { value: '3', label: 'fish' } +]; + +const template = createResourceTemplate('value'); + +export default factory(function Placeholder({ id, middleware: { icache, resource } }) { + return ( + + { + icache.set('value', value); + }} + placeholder={{ + value: 'placeholder', + label: 'I am a placeholder' + }} + > + {{ + label: 'Additional Text' + }} + + {JSON.stringify(icache.getOrSet('value', ''))} + + ); +}); diff --git a/src/examples/src/widgets/typeahead/Placeholder.tsx b/src/examples/src/widgets/typeahead/Placeholder.tsx new file mode 100644 index 0000000000..6fbe3e7be1 --- /dev/null +++ b/src/examples/src/widgets/typeahead/Placeholder.tsx @@ -0,0 +1,60 @@ +import { create, tsx } from '@dojo/framework/core/vdom'; +import icache from '@dojo/framework/core/middleware/icache'; +import Typeahead from '@dojo/widgets/typeahead'; +import Example from '../../Example'; +import { + createResourceTemplate, + createResourceMiddleware, + defaultFilter +} from '@dojo/framework/core/middleware/resources'; +import { largeListOptions } from '../../data'; +import { ListOption } from '@dojo/widgets/list'; + +const resource = createResourceMiddleware(); +const factory = create({ icache, resource }); + +const dataWithDisabled = largeListOptions.map((item) => ({ + ...item, + disabled: Math.random() < 0.1 +})); + +export const listOptionTemplate = createResourceTemplate({ + idKey: 'value', + read: async (req, { put }) => { + // emulate an async request + await new Promise((res) => setTimeout(res, 1000)); + const { offset, size, query } = req; + const filteredData = dataWithDisabled.filter((item) => defaultFilter(query, item)); + put({ data: filteredData.slice(offset, offset + size), total: filteredData.length }, req); + } +}); + +export default factory(function Placeholder({ middleware: { icache, resource } }) { + const strict = icache.getOrSet('strict', true); + return ( + + { + icache.set('value', value); + }} + placeholder={{ + value: 'placeholder', + label: 'This is a placeholder' + }} + required + > + {{ + label: 'Placeholder Typeahead' + }} + + icache.set('strict', (strict = true) => !strict)}> + {strict ? 'Non strict' : 'strict'} + + {JSON.stringify(icache.getOrSet('value', ''))} + + ); +}); diff --git a/src/list/index.tsx b/src/list/index.tsx index 32a3f22ee8..db95930065 100644 --- a/src/list/index.tsx +++ b/src/list/index.tsx @@ -1,19 +1,19 @@ import { RenderResult } from '@dojo/framework/core/interfaces'; -import { focus } from '@dojo/framework/core/middleware/focus'; import dimensions from '@dojo/framework/core/middleware/dimensions'; +import { focus } from '@dojo/framework/core/middleware/focus'; import { createICacheMiddleware } from '@dojo/framework/core/middleware/icache'; +import { createResourceMiddleware } from '@dojo/framework/core/middleware/resources'; +import { throttle } from '@dojo/framework/core/util'; import { create, tsx } from '@dojo/framework/core/vdom'; -import { Keys, isRenderResult } from '../common/util'; -import theme, { ThemeProperties } from '../middleware/theme'; +import { isRenderResult, Keys } from '../common/util'; +import Icon from '../icon'; +import LoadingIndicator from '../loading-indicator'; import offscreen from '../middleware/offscreen'; +import theme, { ThemeProperties } from '../middleware/theme'; import * as listItemCss from '../theme/default/list-item.m.css'; -import * as menuItemCss from '../theme/default/menu-item.m.css'; import * as css from '../theme/default/list.m.css'; +import * as menuItemCss from '../theme/default/menu-item.m.css'; import * as fixedCss from './list.m.css'; -import { createResourceMiddleware } from '@dojo/framework/core/middleware/resources'; -import LoadingIndicator from '../loading-indicator'; -import { throttle } from '@dojo/framework/core/util'; -import Icon from '../icon'; export interface MenuItemProperties { /** Callback used when the item is clicked */ @@ -243,6 +243,8 @@ export interface ListProperties { disabled?: (item: ListOption) => boolean; /** Specifies if the list height should by fixed to the height of the items in view */ height?: 'auto' | 'fixed'; + /** Static option to always show */ + staticOption?: ListOption; } export interface ListChildren { @@ -411,6 +413,7 @@ export const List = factory(function List({ } function renderItems(start: number, count: number) { + const { staticOption } = properties(); const renderedItems = []; const { size: resourceRequestSize } = options(); const { @@ -434,6 +437,11 @@ export const List = factory(function List({ } return get({ ...options(), offset: (page - 1) * options().size }, { read }); }); + if (staticOption !== undefined) { + const { value, label, disabled, divider } = staticOption; + renderedItems[0] = renderItem({ value, label, disabled, divider }, -1); + } + const offset = renderedItems.length; for (let i = 0; i < Math.min(total - start, count); i++) { const index = i + startNode; const page = Math.floor(index / resourceRequestSize) + 1; @@ -442,16 +450,19 @@ export const List = factory(function List({ const items = pageItems[pageIndex]; if (items && items[indexWithinPage]) { const { value, label, disabled, divider } = items[indexWithinPage]; - renderedItems[i] = renderItem({ value, label, disabled, divider }, index); + renderedItems[i + offset] = renderItem( + { value, label, disabled, divider }, + index + ); } else if (!items) { - renderedItems[i] = renderPlaceholder(index); + renderedItems[i + offset] = renderLoading(index); } } } return renderedItems; } - function renderPlaceholder(index: number) { + function renderLoading(index: number) { const itemProps = { widgetId: `${idBase}-item-${index}`, key: `item-${index}`, @@ -646,7 +657,8 @@ export const List = factory(function List({ return divider ? [item, ] : item; } - let { value: selectedValue, draggable, onMove } = properties(); + const { draggable, onMove, staticOption } = properties(); + let { value: selectedValue } = properties(); if (selectedValue === undefined) { if (initialValue !== undefined && initialValue !== icache.get('initial')) { @@ -734,7 +746,7 @@ export const List = factory(function List({ const renderedItemsCount = calculatedItemsInView + 2 * nodePadding; let computedActiveIndex = activeIndex === undefined ? icache.get('activeIndex') : activeIndex; const inputText = icache.get('inputText'); - const { + let { meta: { total = 0 } } = get(options(), { meta: true, read }); if (inputText && inputText !== icache.get('previousInputText') && total) { @@ -791,6 +803,10 @@ export const List = factory(function List({ const offsetY = startNode * itemHeight; const items = renderItems(startNode, renderedItemsCount); + + if (staticOption !== undefined) { + total++; + } const totalContentHeight = total * itemHeight; return ( { r.expect(listWithListItemsAssertion); }); + it('should render list with static option', () => { + const r = renderer( + () => ( + + ), + { middleware: [[getRegistry, mockGetRegistry]] } + ); + r.expect( + listWithListItemsAssertion + .setProperty(WrappedItemWrapper, 'styles', { + height: '180px' + }) + .prepend(WrappedItemContainer, () => [ + + This is a static option + + ]) + ); + }); + it('should render list with auto height', () => { const r = renderer( () => ( diff --git a/src/select/index.tsx b/src/select/index.tsx index af37471cb5..6957760c1b 100644 --- a/src/select/index.tsx +++ b/src/select/index.tsx @@ -5,6 +5,7 @@ import { createICacheMiddleware } from '@dojo/framework/core/middleware/icache'; import { createResourceMiddleware } from '@dojo/framework/core/middleware/resources'; import { uuid } from '@dojo/framework/core/util'; import { create, tsx } from '@dojo/framework/core/vdom'; +import { find } from '@dojo/framework/shim/array'; import { Keys } from '../common/util'; import HelperText from '../helper-text'; import Icon from '../icon'; @@ -12,20 +13,19 @@ import Label from '../label'; import { ItemRendererProperties, List, - ListOption, ListItemProperties, + ListOption, MenuItemProperties } from '../list'; +import LoadingIndicator from '../loading-indicator'; import theme from '../middleware/theme'; import { PopupPosition } from '../popup'; -import TriggerPopup from '../trigger-popup'; -import * as listCss from '../theme/default/list.m.css'; -import * as labelCss from '../theme/default/label.m.css'; import * as iconCss from '../theme/default/icon.m.css'; +import * as labelCss from '../theme/default/label.m.css'; +import * as listCss from '../theme/default/list.m.css'; import * as css from '../theme/default/select.m.css'; +import TriggerPopup from '../trigger-popup'; import bundle from './nls/Select'; -import LoadingIndicator from '../loading-indicator'; -import { find } from '@dojo/framework/shim/array'; export interface SelectProperties { /** Callback called when user selects a value */ @@ -39,7 +39,7 @@ export interface SelectProperties { /** placement of the select menu; 'above' or 'below' */ position?: PopupPosition; /** Placeholder value to show when nothing has been selected */ - placeholder?: string; + placeholder?: ListOption; /** Property to determine if the input is disabled */ disabled?: boolean; /** Sets the helper text of the input */ @@ -100,7 +100,6 @@ export const Select = factory(function Select({ itemsInView = 6, onValidate, onValue, - placeholder = '', position, required, name, @@ -115,7 +114,11 @@ export const Select = factory(function Select({ } = resource.template(template); const [{ items, label } = { items: undefined, label: undefined }] = children(); - let { value } = properties(); + let { value, placeholder } = properties(); + + if (!required && placeholder === undefined) { + placeholder = { value: '', label: '' }; + } if (value === undefined) { if (initialValue !== undefined && initialValue !== icache.get('initial')) { @@ -123,6 +126,11 @@ export const Select = factory(function Select({ icache.set('value', initialValue); } value = icache.get('value'); + if (value === undefined && placeholder !== undefined) { + icache.set('value', placeholder.value); + value = icache.get('value'); + onValue(placeholder); + } } const menuId = icache.getOrSet('menuId', uuid()); @@ -222,6 +230,21 @@ export const Select = factory(function Select({ } } + let valueOption: ListOption | undefined; + if (value && data && (!placeholder || placeholder.value !== value)) { + let found = find(data, (item) => { + return Boolean(item.value && item.value.value === value); + }); + if (found) { + valueOption = found.value; + } else { + const items = get(options({ query: { value } }), { read }); + if (items) { + valueOption = items[0]; + } + } + } + return ( - {(valueOption && valueOption.label) || ( - {placeholder} + {placeholder && placeholder.value === value ? ( + + {placeholder.label} + + ) : ( + valueOption && valueOption.label )} @@ -296,6 +323,7 @@ export const Select = factory(function Select({ closeMenu(); value.value !== icache.get('value') && icache.set('value', value.value); + onValue(value); }} onRequestClose={closeMenu} @@ -310,6 +338,7 @@ export const Select = factory(function Select({ classes={classes} variant={variant} widgetId={menuId} + staticOption={placeholder} > {items} diff --git a/src/select/tests/Select.spec.tsx b/src/select/tests/Select.spec.tsx index 05d49a8d8e..9987564633 100644 --- a/src/select/tests/Select.spec.tsx +++ b/src/select/tests/Select.spec.tsx @@ -73,9 +73,7 @@ const buttonTemplate = assertionTemplate(() => ( name={undefined} value={undefined} > - - - + @@ -100,6 +98,10 @@ const menuTemplate = assertionTemplate(() => ( onBlur={() => {}} initialValue={undefined} itemsInView={6} + staticOption={{ + value: '', + label: '' + }} theme={{}} classes={undefined} variant={undefined} @@ -124,7 +126,7 @@ describe('Select', () => { resource={{ data: options, idKey: 'value', id: 'test' }} itemsInView={10} position="above" - placeholder="test" + placeholder={{ value: 'test', label: 'Test' }} helperText="test-helper" required={true} > @@ -135,6 +137,15 @@ describe('Select', () => { ); const optionalPropertyTemplate = baseTemplate + .setProperty(':root', 'classes', [ + undefined, + css.root, + undefined, + css.valid, + false, + false, + undefined + ]) .prepend('@root', () => [ { variant={undefined} disabled={undefined} forId={'id'} - valid={undefined} + valid={true} required={true} active={false} focused={false} @@ -150,9 +161,9 @@ describe('Select', () => { test-label ]) - .setProperty('@popup', 'position', 'above') - .setProperty('@helperText', 'text', 'test-helper'); + .setProperty('@helperText', 'text', 'test-helper') + .setProperty('@helperText', 'valid', true); h.expect(optionalPropertyTemplate); }); @@ -270,13 +281,43 @@ describe('Select', () => { assert.isTrue(closeMenuStub.calledOnce); }); - it('calls onValue when a menu item is selected', () => { + it('calls onValue immediately with an empty value for non-required select', () => { + const onValueStub = stub(); + + const h = harness( + () => ( + + ), + [compareWidgetId, ignoreMenuTheme] + ); + + const optionalPropertyTemplate = baseTemplate.setProperty(':root', 'classes', [ + undefined, + css.root, + undefined, + false, + false, + false, + undefined + ]); + + h.expect(optionalPropertyTemplate); + + assert.equal(onValueStub.callCount, 1); + assert.isTrue(onValueStub.calledOnceWith({ value: '', label: '' })); + }); + + it('calls onValue when a menu item is selected for required select', () => { const onValueStub = stub(); const closeMenuStub = stub(); const h = harness( () => ( @@ -290,7 +331,12 @@ describe('Select', () => { closeMenuStub ); - h.expect(menuTemplate, () => menuRenderResult); + h.expect( + menuTemplate + .setProperty('@menu', 'initialValue', undefined) + .setProperty('@menu', 'staticOption', undefined), + () => menuRenderResult + ); const [menu] = select('@menu', menuRenderResult); menu.properties.onValue('cat'); @@ -404,9 +450,7 @@ describe('Select', () => { buttonTemplate .setProperty('@trigger', 'value', undefined) .setChildren('@trigger', () => [ - - - , + , @@ -455,6 +499,80 @@ describe('Select', () => { ); }); + it('pulls value from current data', () => { + const onValueStub = stub(); + const toggleOpenStub = stub(); + + const readSub = stub(); + readSub.callsFake((req, { put }) => { + put({ data: [...options], total: options.length }, req); + }); + + const template = createResourceTemplate({ + idKey: 'value', + read: readSub + }); + + const h = harness( + () => , + [compareAriaControls, compareId] + ); + + const triggerRenderResult = h.trigger( + '@popup', + (node) => (node.children as any)[0].trigger, + toggleOpenStub + ); + + h.expect( + buttonTemplate.setProperty('@trigger', 'value', '2').setChildren('@trigger', () => [ + Cat, + + + + ]), + () => triggerRenderResult + ); + }); + + it('handles when no data is returned', () => { + const onValueStub = stub(); + const toggleOpenStub = stub(); + + const readSub = stub(); + readSub.callsFake((req, { put }) => { + put({ data: [], total: options.length }, req); + }); + + const template = createResourceTemplate({ + idKey: 'value', + read: readSub + }); + + const h = harness( + () => , + [compareAriaControls, compareId] + ); + + const triggerRenderResult = h.trigger( + '@popup', + (node) => (node.children as any)[0].trigger, + toggleOpenStub + ); + + h.expect( + buttonTemplate + .setProperty('@trigger', 'value', undefined) + .setChildren('@trigger', () => [ + , + + + + ]), + () => triggerRenderResult + ); + }); + it('invalidates correctly', () => { const onValidate = stub(); const h = harness(() => ( diff --git a/src/typeahead/index.tsx b/src/typeahead/index.tsx index 3682bc9243..806913492b 100644 --- a/src/typeahead/index.tsx +++ b/src/typeahead/index.tsx @@ -1,28 +1,28 @@ -import { create, tsx } from '@dojo/framework/core/vdom'; -import { PopupPosition } from '@dojo/widgets/popup'; import { RenderResult } from '@dojo/framework/core/interfaces'; +import focus from '@dojo/framework/core/middleware/focus'; +import i18n from '@dojo/framework/core/middleware/i18n'; import { createICacheMiddleware } from '@dojo/framework/core/middleware/icache'; +import { createResourceMiddleware } from '@dojo/framework/core/middleware/resources'; +import { create, tsx } from '@dojo/framework/core/vdom'; +import { find } from '@dojo/framework/shim/array'; +import { PopupPosition } from '@dojo/widgets/popup'; +import { Keys } from '../common/util'; +import HelperText from '../helper-text'; import List, { ItemRendererProperties, - ListOption, ListItemProperties, - MenuItemProperties, - ListProperties + ListOption, + ListProperties, + MenuItemProperties } from '../list'; +import LoadingIndicator from '../loading-indicator'; import theme from '../middleware/theme'; -import focus from '@dojo/framework/core/middleware/focus'; -import * as css from '../theme/default/typeahead.m.css'; -import TriggerPopup from '../trigger-popup'; -import { createResourceMiddleware } from '@dojo/framework/core/middleware/resources'; -import TextInput from '../text-input'; import bundle from '../select/nls/Select'; -import i18n from '@dojo/framework/core/middleware/i18n'; -import HelperText from '../helper-text'; +import TextInput from '../text-input'; import * as listCss from '../theme/default/list.m.css'; -import { Keys } from '../common/util'; -import LoadingIndicator from '../loading-indicator'; import * as inputCss from '../theme/default/text-input.m.css'; -import { find } from '@dojo/framework/shim/array'; +import * as css from '../theme/default/typeahead.m.css'; +import TriggerPopup from '../trigger-popup'; import Icon from '../icon'; import * as iconCss from '../theme/default/icon.m.css'; @@ -55,6 +55,8 @@ export interface TypeaheadProperties { itemDisabled?: ListProperties['disabled']; /** Flag to indicate if values other than those in the resource can be entered, defaults to true */ strict?: boolean; + /** Placeholder value to show when nothing has been selected */ + placeholder?: ListOption; /** Flag to indicate if drop down arrow should be shown in trailing section of text input, defaults to false */ hasDownArrow?: boolean; } @@ -131,6 +133,11 @@ export const Typeahead = factory(function Typeahead({ const [{ label, items, leading } = {} as TypeaheadChildren] = children(); + let { placeholder } = properties(); + if (!required && placeholder === undefined) { + placeholder = { value: '', label: '' }; + } + if ( initialValue !== undefined && controlledValue === undefined && @@ -156,6 +163,12 @@ export const Typeahead = factory(function Typeahead({ let valid = icache.get('valid'); let value = icache.get('value'); + if (value === undefined && placeholder !== undefined) { + icache.set('value', placeholder.value); + value = icache.get('value'); + callOnValue(placeholder); + } + let labelValue = icache.get('labelValue'); const listId = `typeahead-list-${id}`; const triggerId = `typeahead-trigger-${id}`; @@ -282,15 +295,19 @@ export const Typeahead = factory(function Typeahead({ } if (!icache.get('selectedOption') && value) { - const currentItems = get(options(), { read }) || []; - const option = currentItems.find((item) => Boolean(item && item.value === value)); - if (option) { - icache.set('selectedOption', option); + if (placeholder && placeholder.value === value) { + icache.set('selectedOption', placeholder); } else { - const findItem = - get({ ...options(), query: { ...options().query, value } }, { read }) || []; - if (findItem) { - icache.set('selectedOption', findItem[0]); + const currentItems = get(options(), { read }) || []; + const option = currentItems.find((item) => Boolean(item && item.value === value)); + if (option) { + icache.set('selectedOption', option); + } else { + const findItem = + get({ ...options(), query: { ...options().query, value } }, { read }) || []; + if (findItem) { + icache.set('selectedOption', findItem[0]); + } } } } @@ -513,6 +530,7 @@ export const Typeahead = factory(function Typeahead({ )} variant={variant} widgetId={listId} + staticOption={placeholder} > {items} diff --git a/src/typeahead/tests/unit/Typeahead.spec.tsx b/src/typeahead/tests/unit/Typeahead.spec.tsx index 28d114c2c9..1ab43415b8 100644 --- a/src/typeahead/tests/unit/Typeahead.spec.tsx +++ b/src/typeahead/tests/unit/Typeahead.spec.tsx @@ -17,6 +17,7 @@ import * as listCss from '../../../theme/default/list.m.css'; import * as iconCss from '../../../theme/default/icon.m.css'; import List from '../../../list'; import { Keys } from '../../../common/util'; +import { createResourceTemplate } from '@dojo/framework/core/middleware/resources'; const { ' _key': key, ...inputTheme } = inputCss as any; const { ' _key': iconKey, ...iconTheme } = iconCss as any; @@ -97,13 +98,17 @@ const contentAssertion = assertion(() => ( activeIndex={0} disabled={undefined} focusable={false} - initialValue={undefined} + initialValue="" itemsInView={undefined} key="menu" onBlur={noop} onRequestClose={noop} onValue={noop} widgetId="typeahead-list-test" + staticOption={{ + label: '', + value: '' + }} theme={{ '@dojo/widgets/list': { ...listTheme, @@ -260,7 +265,14 @@ describe('Typeahead', () => { trigger: [() => {}], content: [() => {}, 'below'] }); - r.expect(baseAssertion); + r.expect( + baseAssertion.replaceChildren(WrappedPopup, () => ({ + trigger: triggerAssertion, + content: contentAssertion + .setProperty(WrappedList, 'initialValue', undefined) + .setProperty(WrappedList, 'staticOption', undefined) + })) + ); // open the drop down r.property(WrappedTrigger, 'onClick'); // focus second item from the drop down, `cat` @@ -278,7 +290,10 @@ describe('Typeahead', () => { ]) .replaceChildren(WrappedPopup, () => ({ trigger: expandedTriggerAssertion, - content: contentAssertion.setProperty(WrappedList, 'activeIndex', 1) + content: contentAssertion + .setProperty(WrappedList, 'activeIndex', 1) + .setProperty(WrappedList, 'initialValue', undefined) + .setProperty(WrappedList, 'staticOption', undefined) })) ); // select second item from the drop down, `cat` @@ -304,6 +319,7 @@ describe('Typeahead', () => { content: contentAssertion .setProperty(WrappedList, 'initialValue', '2') .setProperty(WrappedList, 'activeIndex', 1) + .setProperty(WrappedList, 'staticOption', undefined) })) ); r.property(WrappedTrigger, 'onValue', ''); @@ -324,7 +340,9 @@ describe('Typeahead', () => { trigger: expandedTriggerAssertion .setProperty(WrappedTrigger, 'valid', false) .setProperty(WrappedTrigger, 'value', ''), - content: contentAssertion.setProperty(WrappedList, 'initialValue', undefined) + content: contentAssertion + .setProperty(WrappedList, 'initialValue', undefined) + .setProperty(WrappedList, 'staticOption', undefined) })) ); }); @@ -344,7 +362,14 @@ describe('Typeahead', () => { trigger: [() => {}], content: [() => {}, 'below'] }); - r.expect(baseAssertion); + r.expect( + baseAssertion.replaceChildren(WrappedPopup, () => ({ + trigger: triggerAssertion, + content: contentAssertion + .setProperty(WrappedList, 'initialValue', undefined) + .setProperty(WrappedList, 'staticOption', undefined) + })) + ); // open the drop down r.property(WrappedTrigger, 'onValue', 'unknown'); // focus second item from the drop down, `cat` @@ -365,6 +390,8 @@ describe('Typeahead', () => { .replaceChildren(WrappedPopup, () => ({ trigger: triggerAssertion.setProperty(WrappedTrigger, 'valid', false), content: contentAssertion + .setProperty(WrappedList, 'initialValue', undefined) + .setProperty(WrappedList, 'staticOption', undefined) })) ); }); @@ -404,6 +431,7 @@ describe('Typeahead', () => { trigger: [() => {}] }); r.expect(baseAssertion); + assert.strictEqual(onValueStub.callCount, 1); r.property(WrappedTrigger, 'onKeyDown', Keys.Down, () => {}); r.expect( baseAssertion @@ -421,7 +449,7 @@ describe('Typeahead', () => { content: contentAssertion })) ); - assert.strictEqual(onValueStub.callCount, 0); + assert.strictEqual(onValueStub.callCount, 1); }); it('Should open dropdown on up key', () => { @@ -432,6 +460,7 @@ describe('Typeahead', () => { trigger: [() => {}] }); r.expect(baseAssertion); + assert.strictEqual(onValueStub.callCount, 1); r.property(WrappedTrigger, 'onKeyDown', Keys.Up, () => {}); r.property(WrappedPopup, 'onOpen'); r.expect( @@ -450,7 +479,7 @@ describe('Typeahead', () => { content: contentAssertion })) ); - assert.strictEqual(onValueStub.callCount, 0); + assert.strictEqual(onValueStub.callCount, 1); }); it('Should close the list on escape ', () => { @@ -461,6 +490,7 @@ describe('Typeahead', () => { trigger: [() => {}] }); r.expect(baseAssertion); + assert.strictEqual(onValueStub.callCount, 1); // open the drop down r.property(WrappedTrigger, 'onKeyDown', Keys.Down, () => {}); r.expect( @@ -492,7 +522,7 @@ describe('Typeahead', () => { null ]) ); - assert.strictEqual(onValueStub.callCount, 0); + assert.strictEqual(onValueStub.callCount, 1); }); it('Should select item on enter ', () => { @@ -503,6 +533,7 @@ describe('Typeahead', () => { trigger: [() => {}] }); r.expect(baseAssertion); + assert.strictEqual(onValueStub.callCount, 1); // open the drop down r.property(WrappedTrigger, 'onClick'); // focus second item from the drop down, `cat` @@ -527,7 +558,7 @@ describe('Typeahead', () => { .setProperty(WrappedList, 'activeIndex', 1) })) ); - assert.strictEqual(onValueStub.callCount, 1); + assert.strictEqual(onValueStub.callCount, 2); }); it('Should select item on tab ', () => { @@ -562,7 +593,7 @@ describe('Typeahead', () => { .setProperty(WrappedList, 'activeIndex', 1) })) ); - assert.strictEqual(onValueStub.callCount, 1); + assert.strictEqual(onValueStub.callCount, 2); }); it('Should select value on blur in non-strict mode', () => { @@ -577,6 +608,8 @@ describe('Typeahead', () => { trigger: [() => {}] }); r.expect(nonStrictModeBaseAssertion); + assert.strictEqual(onValueStub.callCount, 1); + assert.deepEqual(onValueStub.firstCall.args, [{ value: '', label: '' }]); // open the drop down r.property(WrappedTrigger, 'onClick'); // focus second item from the drop down, `cat` @@ -599,8 +632,8 @@ describe('Typeahead', () => { content: nonStrictModeContent.setProperty(WrappedList, 'initialValue', 'c') })) ); - assert.strictEqual(onValueStub.callCount, 1); - assert.deepEqual(onValueStub.firstCall.args, [{ value: 'c', label: 'c' }]); + assert.strictEqual(onValueStub.callCount, 2); + assert.deepEqual(onValueStub.secondCall.args, [{ value: 'c', label: 'c' }]); }); it('Should not be able to select a disabled item', () => { @@ -611,6 +644,7 @@ describe('Typeahead', () => { trigger: [() => {}] }); r.expect(baseAssertion); + assert.strictEqual(onValueStub.callCount, 1); // open the drop down r.property(WrappedTrigger, 'onClick'); // focus second item from the drop down, `cat` @@ -635,7 +669,7 @@ describe('Typeahead', () => { content: contentAssertion.setProperty(WrappedList, 'activeIndex', 2) })) ); - assert.strictEqual(onValueStub.callCount, 0); + assert.strictEqual(onValueStub.callCount, 1); }); it('Should not be able to select an item that is considered disabled by the `itemDisabled` property', () => { @@ -660,6 +694,7 @@ describe('Typeahead', () => { trigger: [() => {}] }); r.expect(disabledAssertion); + assert.strictEqual(onValueStub.callCount, 1); r.property(WrappedTrigger, 'onClick'); r.property(WrappedTrigger, 'onKeyDown', Keys.Down, () => {}); r.property(WrappedTrigger, 'onKeyDown', Keys.Enter, () => {}); @@ -679,7 +714,7 @@ describe('Typeahead', () => { content: disabledContentAssertion.setProperty(WrappedList, 'activeIndex', 1) })) ); - assert.strictEqual(onValueStub.callCount, 0); + assert.strictEqual(onValueStub.callCount, 1); }); it('Should not be able to select an invalid item', () => { @@ -690,6 +725,7 @@ describe('Typeahead', () => { trigger: [() => {}] }); r.expect(baseAssertion); + assert.strictEqual(onValueStub.callCount, 1); r.property(WrappedTrigger, 'onClick'); r.property(WrappedTrigger, 'onValue', 'Unknown'); r.property(WrappedTrigger, 'onKeyDown', Keys.Enter, () => {}); @@ -704,7 +740,7 @@ describe('Typeahead', () => { null ]) ); - assert.strictEqual(onValueStub.callCount, 0); + assert.strictEqual(onValueStub.callCount, 1); }); it('Should be able to select a free text value in non-strict mode', () => { @@ -719,6 +755,7 @@ describe('Typeahead', () => { trigger: [() => {}] }); r.expect(nonStrictModeBaseAssertion); + assert.strictEqual(onValueStub.callCount, 1); r.property(WrappedTrigger, 'onClick'); r.property(WrappedTrigger, 'onValue', 'Unknown'); r.property(WrappedTrigger, 'onKeyDown', Keys.Enter, () => {}); @@ -742,7 +779,7 @@ describe('Typeahead', () => { ) })) ); - assert.strictEqual(onValueStub.callCount, 1); + assert.strictEqual(onValueStub.callCount, 2); }); it('Required typeahead should be validated when item is selected and then the typeahead blurred', () => { @@ -759,7 +796,14 @@ describe('Typeahead', () => { trigger: [() => {}], content: [toggleClosedStub, 'above'] }); - r.expect(nonStrictModeBaseAssertion); + r.expect( + nonStrictModeBaseAssertion.replaceChildren(WrappedPopup, () => ({ + trigger: triggerAssertion, + content: nonStrictModeContent + .setProperty(WrappedList, 'initialValue', undefined) + .setProperty(WrappedList, 'staticOption', undefined) + })) + ); r.property(WrappedPopup, 'onClose'); r.property(WrappedList, 'onValue', { value: '1', @@ -780,7 +824,9 @@ describe('Typeahead', () => { trigger: triggerAssertion .setProperty(WrappedTrigger, 'value', 'Dog') .setProperty(WrappedTrigger, 'valid', true), - content: nonStrictModeContent.setProperty(WrappedList, 'initialValue', '1') + content: nonStrictModeContent + .setProperty(WrappedList, 'staticOption', undefined) + .setProperty(WrappedList, 'initialValue', '1') })); r.expect(validatedAssertion); r.property(WrappedTrigger, 'onBlur'); @@ -830,9 +876,10 @@ describe('Typeahead', () => { aria: { ...current.aria, expanded: 'true' } }; }), - content: nonStrictModeContent.setProperty(WrappedList, 'initialValue', undefined) + content: nonStrictModeContent })); r.expect(expandedAssertion); + assert.strictEqual(onValueStub.callCount, 1); r.property(WrappedTrigger, 'onValue', ''); r.property(WrappedTrigger, 'onBlur'); r.expect( @@ -857,7 +904,7 @@ describe('Typeahead', () => { content: nonStrictModeContent.setProperty(WrappedList, 'initialValue', '') })) ); - assert.strictEqual(onValueStub.callCount, 0); + assert.strictEqual(onValueStub.callCount, 1); }); // it('deals with loading items in non strict mode', async () => { @@ -1150,4 +1197,204 @@ describe('Typeahead', () => { it('opens typeahead onkeydown `Space` on down arrow', () => { testDownArrowOpening('onkeydown', { which: Keys.Space, preventDefault: noop }); }); + + it('should create placeholder object when not required and no placeholder provided', () => { + const toggleClosedStub = sb.stub(); + const r = renderer(() => ( + + )); + r.child(WrappedPopup, { + trigger: [() => {}], + content: [toggleClosedStub, 'above'] + }); + r.expect(nonStrictModeBaseAssertion); + + assert.strictEqual(onValueStub.callCount, 1); + assert.deepEqual(onValueStub.firstCall.args, [{ value: '', label: '' }]); + }); + + it('should should use provided placeholder', () => { + const toggleClosedStub = sb.stub(); + const r = renderer(() => ( + + )); + + r.child(WrappedPopup, { + trigger: [() => {}], + content: [toggleClosedStub, 'above'] + }); + r.expect( + nonStrictModeBaseAssertion.replaceChildren(WrappedPopup, () => ({ + trigger: triggerAssertion.setProperty( + WrappedTrigger, + 'value', + 'This is a placeholder' + ), + content: nonStrictModeContent + .setProperty(WrappedList, 'initialValue', 'placeholder') + .setProperty(WrappedList, 'staticOption', { + value: 'placeholder', + label: 'This is a placeholder' + }) + })) + ); + + assert.strictEqual(onValueStub.callCount, 1); + assert.deepEqual(onValueStub.firstCall.args, [ + { value: 'placeholder', label: 'This is a placeholder' } + ]); + }); + + it('should not create placeholder object when required and no placeholder provided', () => { + const toggleClosedStub = sb.stub(); + const r = renderer(() => ( + + )); + r.child(WrappedPopup, { + trigger: [() => {}], + content: [toggleClosedStub, 'above'] + }); + r.expect( + nonStrictModeBaseAssertion.replaceChildren(WrappedPopup, () => ({ + trigger: triggerAssertion, + content: nonStrictModeContent + .setProperty(WrappedList, 'initialValue', undefined) + .setProperty(WrappedList, 'staticOption', undefined) + })) + ); + + assert.strictEqual(onValueStub.callCount, 0); + }); + + it('queries for matching value if not found', () => { + const onValueStub = sb.stub(); + const toggleClosedStub = sb.stub(); + + const readSub = sb.stub(); + readSub.callsFake((req, { put }) => { + if (req.query.value === '4') { + put({ data: [{ value: '4', label: 'Frog' }], total: 1 }, req); + } else { + put({ data, total: data.length }, req); + } + }); + + const template = createResourceTemplate({ + idKey: 'value', + read: readSub + }); + + const r = renderer(() => ( + + )); + + r.child(WrappedPopup, { + trigger: [() => {}], + content: [toggleClosedStub, 'above'] + }); + r.expect( + nonStrictModeBaseAssertion.replaceChildren(WrappedPopup, () => ({ + trigger: triggerAssertion.setProperty(WrappedTrigger, 'value', 'Frog'), + content: nonStrictModeContent + .setProperty(WrappedList, 'initialValue', '4') + .setProperty(WrappedList, 'staticOption', undefined) + })) + ); + }); + + it('pulls value from current data', () => { + const onValueStub = sb.stub(); + const toggleClosedStub = sb.stub(); + + const readSub = sb.stub(); + readSub.callsFake((req, { put }) => { + put({ data, total: data.length }, req); + }); + + const template = createResourceTemplate({ + idKey: 'value', + read: readSub + }); + + const r = renderer(() => ( + + )); + + r.child(WrappedPopup, { + trigger: [() => {}], + content: [toggleClosedStub, 'above'] + }); + r.expect( + nonStrictModeBaseAssertion.replaceChildren(WrappedPopup, () => ({ + trigger: triggerAssertion.setProperty(WrappedTrigger, 'value', 'Cat'), + content: nonStrictModeContent + .setProperty(WrappedList, 'initialValue', '2') + .setProperty(WrappedList, 'staticOption', undefined) + })) + ); + }); + + it('handles when no data is returned', () => { + const onValueStub = sb.stub(); + const toggleClosedStub = sb.stub(); + + const readSub = sb.stub(); + readSub.callsFake((req, { put }) => { + put({ data: [], total: data.length }, req); + }); + + const template = createResourceTemplate({ + idKey: 'value', + read: readSub + }); + + const r = renderer(() => ( + + )); + + r.child(WrappedPopup, { + trigger: [() => {}], + content: [toggleClosedStub, 'above'] + }); + r.expect( + nonStrictModeBaseAssertion.replaceChildren(WrappedPopup, () => ({ + trigger: triggerAssertion.setProperty(WrappedTrigger, 'value', ''), + content: nonStrictModeContent + .setProperty(WrappedList, 'initialValue', '2') + .setProperty(WrappedList, 'staticOption', undefined) + })) + ); + }); });
{`Clicked on: ${JSON.stringify(icache.getOrSet('value', ''))}`}
{JSON.stringify(icache.getOrSet('value', ''))}