diff --git a/src/Select.ts b/src/Select.ts index ff2c62a3..257ea3c2 100644 --- a/src/Select.ts +++ b/src/Select.ts @@ -1,8 +1,11 @@ import { Container } from '@pixi/display'; import { Graphics } from '@pixi/graphics'; import { Text, TextStyle } from '@pixi/text'; +import { Sprite } from '@pixi/sprite'; +import { Texture } from '@pixi/core'; +import { ColorSource } from '@pixi/color'; import { Signal } from 'typed-signals'; -import { FancyButton } from './FancyButton'; +import { ButtonOptions, FancyButton } from './FancyButton'; import { ScrollBox, ScrollBoxOptions } from './ScrollBox'; import { getView } from './utils/helpers/view'; @@ -13,39 +16,91 @@ type Offset = { x: number; }; -export type SelectItemsOptions = { - items: string[]; - backgroundColor: number | string; +type ItemOptions = { + /** Width of a dropdown item */ width: number; + /** Height of a dropdown item */ height: number; - hoverColor?: number; - textStyle?: Partial; + /** Specify a background color for the item view, defaults to transparency */ + backgroundColor?: ColorSource; + /** Specify a hover color for the item view, defaults to transparency */ + hoverColor?: ColorSource; + /** Specify the corner radius of the item view, defaults to 0 */ radius?: number; + /** Specify the padding around the content within the item view, defaults to 0 */ + padding?: number; }; -export type SelectOptions = { +type BaseSelectOptions = { + /** Options for the individual dropdown item */ + itemOptions: ItemOptions; + /** Texture alias or a view container that will be used for the closed state */ closedBG: string | Container; + /** Texture alias or a view container that will be used for the open state */ openBG: string | Container; - textStyle?: Partial; + /** Specify the initial selected index, otherwise it will use the first item */ selected?: number; - selectedTextOffset?: { x?: number; y?: number }; - - items: SelectItemsOptions; - - scrollBoxOffset?: { x?: number; y?: number }; - scrollBoxWidth?: number; - scrollBoxHeight?: number; - scrollBoxRadius?: number; - - visibleItems?: number; - + /** Specify the selected item offset within the open/close view */ + selectedItemOffset?: { x?: number; y?: number }; + /** Specify the dropdown scroll box options */ scrollBox?: ScrollBoxOptions & { offset?: Offset; }; + /** Specify the amount of items should be displayed on the dropdown */ + visibleItems?: number; }; +type TextSelectOptions = { + /** Specify which type of content is being used in the dropdown */ + type: 'text'; + /** Override the text class to use, otherwise it will use the default Pixi Text */ + textClass?: new (...args: any[]) => any; + /** + * Custom options to be passed to the custom text class which will be + * in form of an array of arguments and will append after the text string when instantiating. + * If it's a single option object, please also wrap it in an array. + * + * eg: + * - [arg1, arg2, arg3] => new TextClass(text, arg1, arg2, arg3) + * - [{ arg1, arg2, arg3 }] => new TextClass(text, { arg1, arg2, arg3 }) + * + * If not provided, it will use the specified text style if also supplied. + */ + textArgs?: any; + /** + * This is explicity for custom text classes that expect text string to be supplied + * within a single options object. The text string will be added to the object with the + * key specified here. + * + * eg: + * { `consolidateOptionsWithKey`: 'label', textArgs: [{ arg1, arg2, arg3 }] } + * => new TextClass({ arg1, arg2, arg3, label: text }) + */ + consolidateOptionsWithKey?: string; + /** + * Provide a function to update the text view. + * Good for the custom text class that doesn't have a direct text property + * that the default update function makes use of. + */ + textUpdate?: (view: any, text: string) => void; + /** Specify the text style options */ + textStyle?: Partial; + /** Provide an array of text strings for the dropdown */ + items: string[]; +} & BaseSelectOptions; + +type SpriteSelectOptions = { + /** Specify which type of content is being used in the dropdown */ + type: 'sprite'; + /** Provide an array of sprite source for the dropdown, can be aliases or Texture instances */ + items: (Texture | string)[]; +} & BaseSelectOptions; + +export type SelectOptions = TextSelectOptions | SpriteSelectOptions; + /** - * Container-based component that gives us a selection dropdown. + * Container-based component that gives us a selection dropdown + * for a list of items which can be either text or sprites. * * It is a composition of a {@link Button} and a {@link ScrollBox}. * @example @@ -70,55 +125,64 @@ export type SelectOptions = { export class Select extends Container { + protected options: SelectOptions; protected openButton!: FancyButton; protected closeButton!: FancyButton; protected openView!: Container; protected scrollBox: ScrollBox; - /** Selected value ID. */ - value: number; + /** Selected index. */ + index: number; + + /** Selected value. */ + value: string | Texture; /** Fires when selected value is changed. */ - onSelect: Signal<(value: number, text: string) => void>; + onSelect: Signal<(index: number, value: string | Texture) => void>; constructor(options?: SelectOptions) { super(); - this.onSelect = new Signal(); - - if (options) - { - this.init(options); - } + if (options) this.init(options); } /** * Initiates Select. * @param root0 * @param root0.closedBG - * @param root0.textStyle * @param root0.items * @param root0.openBG * @param root0.selected - * @param root0.selectedTextOffset + * @param root0.selectedItemOffset * @param root0.scrollBox * @param root0.visibleItems + * @param root0.type + * @param options */ - init({ closedBG, textStyle, items, openBG, selected, selectedTextOffset, scrollBox, visibleItems }: SelectOptions) + init(options: SelectOptions) { - if (this.openView && this.openView !== openBG) - { - this.removeChild(this.openView); - } + this.options = options; + + // Destructure common options. + const { closedBG, openBG, items, itemOptions, selected, scrollBox, visibleItems, selectedItemOffset } = options; - // openButton + const baseItemOpts = { + textOffset: selectedItemOffset, + iconOffset: selectedItemOffset, + padding: itemOptions.padding ?? 0, + }; + + // Get the item options, containing a view instance. + const openItemOpts = this.getContentOptions(items[selected ?? 0]); + + // Create / update the open button. if (!this.openButton) { this.openButton = new FancyButton({ defaultView: getView(closedBG), - text: new Text(items?.items ? items.items[0] : '', textStyle), - textOffset: selectedTextOffset + ...baseItemOpts, + ...openItemOpts, }); this.openButton.onPress.connect(() => this.toggle()); this.addChild(this.openButton); @@ -126,28 +190,34 @@ export class Select extends Container else { this.openButton.defaultView = getView(closedBG); - this.openButton.textView = new Text(items?.items ? items.items[0] : '', textStyle); - - this.openButton.textOffset = selectedTextOffset; + this.openButton.textView = openItemOpts.text; + this.openButton.iconView = openItemOpts.icon; + this.openButton.textOffset = this.openButton.iconOffset = selectedItemOffset; + this.openButton.padding = openItemOpts.padding; } - // openView + // Add the open view. if (this.openView !== openBG) { + // Remove the old open view, if exists. + if (this.openView) this.removeChild(this.openView); this.openView = getView(openBG); this.openView.visible = false; this.addChild(this.openView); } - // closeButton + // Get the item options, containing another view instance. + const closeItemOpts = this.getContentOptions(items[selected ?? 0]); + + // Create / update the close button. if (!this.closeButton) { this.closeButton = new FancyButton({ defaultView: new Graphics() .beginFill(0x000000, 0.00001) .drawRect(0, 0, this.openButton.width, this.openButton.height), - text: new Text(items?.items ? items.items[0] : '', textStyle), - textOffset: selectedTextOffset + ...baseItemOpts, + ...closeItemOpts }); this.closeButton.onPress.connect(() => this.toggle()); this.openView.addChild(this.closeButton); @@ -158,12 +228,13 @@ export class Select extends Container .beginFill(0x000000, 0.00001) .drawRect(0, 0, this.openButton.width, this.openButton.height); - this.closeButton.textView = new Text(items?.items ? items.items[0] : '', textStyle); - - this.openButton.textOffset = selectedTextOffset; + this.closeButton.textView = closeItemOpts.text; + this.closeButton.iconView = closeItemOpts.icon; + this.closeButton.textOffset = this.openButton.iconOffset = selectedItemOffset; + this.closeButton.padding = closeItemOpts.padding; } - // ScrollBox + // Create / clean the scroll box. if (!this.scrollBox) { this.scrollBox = new ScrollBox(); @@ -175,6 +246,7 @@ export class Select extends Container this.scrollBox.removeItems(); } + // Update the scroll box. this.scrollBox.init({ type: 'vertical', elementsMargin: 0, @@ -193,6 +265,7 @@ export class Select extends Container this.scrollBox.y += scrollBox.offset.y ?? 0; } + // Add items to the dropdown. this.addItems(items, selected); } @@ -201,24 +274,20 @@ export class Select extends Container * @param items * @param selected */ - addItems(items: SelectItemsOptions, selected = 0) + addItems(items: string[] | (Texture | string)[], selected = 0) { - this.convertItemsToButtons(items).forEach((button, id) => + this.convertItemsToButtons(items).forEach((button, i) => { - const text = button.text; + const value = items[i]; - if (id === selected) - { - this.openButton.text = text; - this.closeButton.text = text; - } + if (i === selected) this.updateSelected(value); button.onPress.connect(() => { - this.value = id; - this.onSelect.emit(id, text); - this.openButton.text = text; - this.closeButton.text = text; + this.index = i; + this.value = value; + this.onSelect.emit(i, value); + this.updateSelected(value); this.close(); }); @@ -256,32 +325,84 @@ export class Select extends Container this.openButton.visible = true; } - protected convertItemsToButtons({ - items, - backgroundColor, - hoverColor, - width, - height, - textStyle, - radius - }: SelectItemsOptions): FancyButton[] + protected updateSelected(value: string | Texture) { - const buttons: FancyButton[] = []; + if (this.options.type === 'sprite') + { + const openView = this.openButton.iconView as Sprite; + const closeView = this.closeButton.iconView as Sprite; + const texture = typeof value === 'string' ? Texture.from(value) : value; + + openView.texture = texture; + closeView.texture = texture; + + return; + } - items.forEach((item) => + const openView = this.openButton.textView; + const closeView = this.closeButton.textView; + const text = value as string; + + if (this.options.textUpdate) { - const defaultView = new Graphics().beginFill(backgroundColor).drawRoundedRect(0, 0, width, height, radius); + this.options.textUpdate(openView, text); + this.options.textUpdate(closeView, text); - const color = hoverColor ?? backgroundColor; - const hoverView = new Graphics().beginFill(color).drawRoundedRect(0, 0, width, height, radius); + return; + } - const text = new Text(item, textStyle); + openView.text = text; + closeView.text = text; + } - const button = new FancyButton({ defaultView, hoverView, text }); + protected convertItemsToButtons(items: string[] | (Texture | string)[]): FancyButton[] + { + const buttons: FancyButton[] = []; - buttons.push(button); - }); + items.forEach((item) => buttons.push(this.createItemButton(item))); return buttons; } + + protected createItemButton(item: string | Texture) + { + const { backgroundColor, hoverColor, width, height, radius, padding } = this.options.itemOptions; + const defaultView = new Graphics().beginFill(backgroundColor).drawRoundedRect(0, 0, width, height, radius); + const color = hoverColor ?? backgroundColor; + const hoverView = new Graphics().beginFill(color).drawRoundedRect(0, 0, width, height, radius); + + return new FancyButton({ + defaultView, + hoverView, + padding: padding ?? 0, + ...this.getContentOptions(item) + }); + } + + protected getContentOptions(item: string | Texture): Partial + { + if (this.options.type === 'text') + { + const TextClass = this.options.textClass ?? Text; + const style = this.options.textStyle; + const args = this.options.textArgs; + const textKey = this.options.consolidateOptionsWithKey; + + if (textKey) + { + return { text: new TextClass({ [textKey]: item, ...style, ...args }) }; + } + + if (args) + { + return { text: new TextClass(item, ...args) }; + } + + return { text: new TextClass(item, style) }; + } + + const sprite = Sprite.from(item); + + return { icon: sprite }; + } } diff --git a/src/stories/select/SelectGraphics.stories.ts b/src/stories/select/SelectGraphics.stories.ts index 0438958b..68adcef7 100644 --- a/src/stories/select/SelectGraphics.stories.ts +++ b/src/stories/select/SelectGraphics.stories.ts @@ -49,16 +49,16 @@ export const UseGraphics: StoryFn = ({ // Component usage !!! // Important: in order scroll to work, you have to call update() method in your game loop. const select = new Select({ + type: 'text', closedBG: getClosedBG(backgroundColor, width, height, radius), openBG: getOpenBG(dropDownBackgroundColor, width, height, radius), textStyle, - items: { - items, + items, + itemOptions: { backgroundColor, hoverColor, width, height, - textStyle, radius }, scrollBox: { @@ -70,10 +70,10 @@ export const UseGraphics: StoryFn = ({ select.y = 10; - select.onSelect.connect((_, text) => + select.onSelect.connect((id, text) => { onSelect({ - id: select.value, + id, text }); }); diff --git a/src/stories/select/SelectItems.stories.ts b/src/stories/select/SelectItems.stories.ts new file mode 100644 index 00000000..a44c4bbb --- /dev/null +++ b/src/stories/select/SelectItems.stories.ts @@ -0,0 +1,122 @@ +import { Graphics } from '@pixi/graphics'; +import { Container } from '@pixi/display'; +import { Sprite } from '@pixi/sprite'; +import { argTypes, getDefaultArgs } from '../utils/argTypes'; +import { Select } from '../../Select'; +import { action } from '@storybook/addon-actions'; +import { preload } from '../utils/loader'; +import { centerElement } from '../../utils/helpers/resize'; +import type { StoryFn } from '@storybook/types'; +import { getColor } from '../utils/color'; + +const args = { + backgroundColor: '#F5E3A9', + dropDownBackgroundColor: '#F5E3A9', + dropDownHoverColor: '#A5E24D', + fontColor: '#000000', + fontSize: 28, + width: 200, + height: 120, + radius: 15, + padding: 15, + onSelect: action('Item selected') +}; + +export const UseGraphicalItems: StoryFn = ({ + width, + height, + radius, + backgroundColor, + dropDownBackgroundColor, + dropDownHoverColor, + onSelect, + padding, +}: any) => +{ + const view = new Container(); + + backgroundColor = getColor(backgroundColor); + dropDownBackgroundColor = getColor(dropDownBackgroundColor); + const hoverColor = getColor(dropDownHoverColor); + const items = ['avatar-01.png', 'avatar-02.png', 'avatar-03.png', 'avatar-04.png', 'avatar-05.png']; + + // Component usage !!! + // Important: in order scroll to work, you have to call update() method in your game loop. + const select = new Select({ + type: 'sprite', + closedBG: getClosedBG(backgroundColor, width, height, radius), + openBG: getOpenBG(dropDownBackgroundColor, width, height, radius), + items, + itemOptions: { + backgroundColor, + hoverColor, + width, + height, + radius, + padding, + }, + scrollBox: { + width, + height: height * 5, + radius + } + }); + + select.y = 10; + + select.onSelect.connect((_, text) => + { + onSelect({ + id: select.value, + text + }); + }); + + view.addChild(select); + + return { + view, + resize: () => centerElement(view, 0.5, 0) + }; +}; + +function getClosedBG(backgroundColor: number, width: number, height: number, radius: number) +{ + const closedBG = new Graphics().beginFill(backgroundColor).drawRoundedRect(0, 0, width, height, radius); + + preload(['arrow_down.png']).then(() => + { + const arrowDown = Sprite.from('arrow_down.png'); + + arrowDown.anchor.set(0.5); + arrowDown.x = width * 0.9; + arrowDown.y = height / 2; + closedBG.addChild(arrowDown); + }); + + return closedBG; +} + +function getOpenBG(backgroundColor: number, width: number, height: number, radius: number) +{ + const openBG = new Graphics().beginFill(backgroundColor).drawRoundedRect(0, 0, width, height * 6, radius); + + preload(['arrow_down.png']).then(() => + { + const arrowUp = Sprite.from('arrow_down.png'); + + arrowUp.angle = 180; + arrowUp.anchor.set(0.5); + arrowUp.x = width * 0.9; + arrowUp.y = height / 2; + openBG.addChild(arrowUp); + }); + + return openBG; +} + +export default { + title: 'Components/Select/Use Graphical Items', + argTypes: argTypes(args), + args: getDefaultArgs(args) +}; diff --git a/src/stories/select/SelectSprite.stories.ts b/src/stories/select/SelectSprite.stories.ts index b672268e..edbc0d88 100644 --- a/src/stories/select/SelectSprite.stories.ts +++ b/src/stories/select/SelectSprite.stories.ts @@ -35,19 +35,19 @@ export const UseSprite: StoryFn = ({ fontColor, fontSize, itemsAmount, dropDownH // Component usage !!! // Important: in order scroll to work, you have to call update() method in your game loop. select = new Select({ + type: 'text', closedBG: `select_closed.png`, openBG: `select_open.png`, textStyle, - items: { - items, + items, + itemOptions: { backgroundColor: 'RGBA(0, 0, 0, 0.0001)', hoverColor, width: 200, height: 50, - textStyle, - radius: 25 + radius: 25, }, - selectedTextOffset: { + selectedItemOffset: { y: -13 }, scrollBox: { @@ -63,10 +63,10 @@ export const UseSprite: StoryFn = ({ fontColor, fontSize, itemsAmount, dropDownH select.y = 10; - select.onSelect.connect((_, text) => + select.onSelect.connect((id, text) => { onSelect({ - id: select.value, + id, text }); });