{
+ this.ariaForm = { ...ariaForm }
+ }
+
+ /**
+ * EVENT BINDING
+ * ------------------------------------------------------
+ */
+ handleAutoFill = async (ev: Event) => {
+ this.log('(handleAutoFill)', ev, this.nativeEl.value)
+ this.autoFillUtil.handleAutoFill(ev)
+ }
+
+ handleKeyDown = (ev: KeyboardEvent) => {
+ if (ev && ev.key) {
+ if (this.isExpanded) {
+ /**
+ * ⬇️ Arrow up key
+ */
+ if (isArrowDownKey(ev)) {
+ stopEventBubbling(ev)
+ this.listEl?.focusNext()
+ /**
+ * ⬆️ Arrow up key
+ */
+ } else if (isArrowUpKey(ev)) {
+ stopEventBubbling(ev)
+ this.listEl?.focusPrevious()
+ /**
+ * Go to top of the list
+ */
+ } else if (ev.key === 'Home' || ev.key === 'PageUp') {
+ stopEventBubbling(ev)
+ this.listEl?.focusFirst()
+ /**
+ * Go to bottom of the list
+ */
+ } else if (ev.key === 'End' || ev.key === 'PageDown') {
+ stopEventBubbling(ev)
+ this.listEl?.focusLast()
+ /**
+ * Select focused option
+ */
+ } else if (isEnterKey(ev)) {
+ stopEventBubbling(ev)
+ this.listEl?.selectByFocus()
+ /**
+ * Close list
+ */
+ } else if (ev.key === 'Tab' || isEscapeKey(ev)) {
+ this.popupUtil.collapseList()
+ /**
+ * Focus on label
+ */
+ } else if (ev.key.length === 1) {
+ this.focusUtil.focusOptionByLabel(ev.key)
+ }
+ } else {
+ /**
+ * Open list
+ */
+ if (isEnterKey(ev) || isSpaceKey(ev)) {
+ stopEventBubbling(ev)
+ this.popupUtil.expandList()
+ /**
+ * Focus on label
+ */
+ } else if (ev.key.length === 1) {
+ this.focusUtil.focusOptionByLabel(ev.key, { select: true })
+ }
+ }
+ } else {
+ // Close the popup on autofill
+ if (this.isExpanded) {
+ this.popupUtil.collapseList()
+ }
+ }
+ }
+
+ /**
+ * RENDER
+ * ------------------------------------------------------
+ */
+
+ render() {
+ const block = BEM.block('dropdown')
+
+ return (
+
+ this.eventsUtil.handleClick(ev)}
+ >
+
+ this.valueUtil.removeOption(option)}
+ >
+
+ (this.nativeEl = el)}
+ onChange={ev => this.handleAutoFill(ev)}
+ onFocus={ev => this.eventsUtil.handleFocus(ev)}
+ onBlur={ev => this.eventsUtil.handleBlur(ev)}
+ onKeyDown={ev => this.handleKeyDown(ev)}
+ >
+ (this.selectEl = el)}
+ >
+
+
+ (this.panelEl = el)}
+ refListEl={el => (this.listEl = el)}
+ >
+
+
+
+ )
+ }
+}
+
+let balDropdownIds = 0
diff --git a/packages/core/src/components/bal-dropdown/bal-dropdown.vars.sass b/packages/core/src/components/bal-dropdown/bal-dropdown.vars.sass
new file mode 100644
index 0000000000..79e1ec7a69
--- /dev/null
+++ b/packages/core/src/components/bal-dropdown/bal-dropdown.vars.sass
@@ -0,0 +1,74 @@
+/**
+ * @prop --bal-dropdown-control-background: tbd
+ * @prop --bal-dropdown-control-background-hover: tbd
+ * @prop --bal-dropdown-control-background-invalid: tbd
+ * @prop --bal-dropdown-control-background-disabled: tbd
+ * @prop --bal-dropdown-control-input-background: tbd
+ * @prop --bal-dropdown-control-native-input-background: tbd
+ * @prop --bal-dropdown-control-native-input-background-hover: tbd
+ * @prop --bal-dropdown-control-input-inverted-footer-background: tbd
+ * @prop --bal-dropdown-control-input-inverted-footer-background-hover: tbd
+ * @prop --bal-dropdown-control-input-multiple-background: tbd
+ * @prop --bal-dropdown-control-input-multiple-background-read-only-selection: tbd
+ * @prop --bal-dropdown-control-input-option-background: tbd
+ * @prop --bal-dropdown-control-input-option-background-selected: tbd
+ * @prop --bal-dropdown-control-input-option-background-focused: tbd
+ * @prop --bal-dropdown-control-input-option-background-hover: tbd
+ *
+ * @prop --bal-dropdown-control-border-radius: tbd
+ *
+ * @prop --bal-dropdown-popover-border-color: tbd
+ * @prop --bal-dropdown-control-border-color: tbd
+ * @prop --bal-dropdown-control-border-color-focused: tbd
+ * @prop --bal-dropdown-control-border-color-hover: tbd
+ * @prop --bal-dropdown-control-border-color-invalid: tbd
+ * @prop --bal-dropdown-control-border-color-disabled: tbd
+ * @prop --bal-dropdown-control-border-color-focus-within: tbd
+ * @prop --bal-dropdown-option-border-top-color: tbd
+ *
+ * @prop --bal-dropdown-popover-empty-text-color: tbd
+ * @prop --bal-dropdown-control-text-color: tbd
+ * @prop --bal-dropdown-control-text-color-focused: tbd
+ * @prop --bal-dropdown-input-text-color-disabled: tbd
+ * @prop --bal-dropdown-control-inverted-footer-native-input-text-color: tbd
+ * @prop --bal-dropdown-option-content-label-text-color: tbd
+ */
+
+:root
+ //
+ // background colors
+ --bal-dropdown-control-background: var(--bal-color-white)
+ --bal-dropdown-control-background-hover: var(--bal-form-field-control-background-hover)
+ --bal-dropdown-control-background-invalid: var(--bal-form-field-control-danger-background)
+ --bal-dropdown-control-background-disabled: var(--bal-form-field-control-disabled-background)
+ --bal-dropdown-control-input-background: var(--bal-color-grey-1)
+ --bal-dropdown-control-native-input-background: transparent
+ --bal-dropdown-control-native-input-background-hover: transparent
+ --bal-dropdown-control-input-inverted-footer-background: transparent
+ --bal-dropdown-control-input-inverted-footer-background-hover: transparent
+ --bal-dropdown-control-input-multiple-background: transparent
+ --bal-dropdown-control-input-multiple-background-read-only-selection: transparent
+ --bal-dropdown-control-input-option-background: transparent
+ --bal-dropdown-control-input-option-background-selected: var(--bal-color-primary-1)
+ --bal-dropdown-control-input-option-background-focused: var(--bal-color-grey-2)
+ --bal-dropdown-control-input-option-background-hover: var(--bal-color-grey-2)
+ //
+ // border radius
+ --bal-dropdown-control-border-radius: var(--bal-form-field-control-radius)
+ // border colors
+ --bal-dropdown-popover-border-color: var(--bal-color-grey-2)
+ --bal-dropdown-control-border-color: var(--bal-form-field-control-border-color)
+ --bal-dropdown-control-border-color-focused: var(--bal-color-primary)
+ --bal-dropdown-control-border-color-hover: var(--bal-form-field-control-border-color-hover)
+ --bal-dropdown-control-border-color-invalid: var(--bal-form-field-control-danger-border-color)
+ --bal-dropdown-control-border-color-disabled: var(--bal-form-field-control-disabled-border-color)
+ --bal-dropdown-control-border-color-focus-within: var(--bal-color-primary)
+ --bal-dropdown-option-border-top-color: var(--bal-color-grey-2)
+ //
+ // text colors
+ --bal-dropdown-popover-empty-text-color: var(--bal-form-field-control-color)
+ --bal-dropdown-control-text-color: var(--bal-form-field-control-color)
+ --bal-dropdown-control-text-color-focused: var(--bal-color-primary)
+ --bal-dropdown-input-text-color-disabled: var(--bal-form-field-label-disabled-color)
+ --bal-dropdown-control-inverted-footer-native-input-text-color: var(--bal-color-text-white)
+ --bal-dropdown-option-content-label-text-color: var(--bal-form-field-control-color)
diff --git a/packages/core/src/components/bal-dropdown/test/bal-dropdown.a11y.html b/packages/core/src/components/bal-dropdown/test/bal-dropdown.a11y.html
new file mode 100644
index 0000000000..51380bfcf2
--- /dev/null
+++ b/packages/core/src/components/bal-dropdown/test/bal-dropdown.a11y.html
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Year
+
+
+ 1988
+ 1989
+ 1990
+ 1991
+ 1992
+
+
+
+
+
+
+
+
+
diff --git a/packages/core/src/components/bal-dropdown/test/bal-dropdown.auto-fill.html b/packages/core/src/components/bal-dropdown/test/bal-dropdown.auto-fill.html
new file mode 100644
index 0000000000..2d715a3717
--- /dev/null
+++ b/packages/core/src/components/bal-dropdown/test/bal-dropdown.auto-fill.html
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Autocomplete
+
+
+
+
+
diff --git a/packages/core/src/components/bal-dropdown/test/bal-dropdown.cy.html b/packages/core/src/components/bal-dropdown/test/bal-dropdown.cy.html
new file mode 100644
index 0000000000..a78a7046ec
--- /dev/null
+++ b/packages/core/src/components/bal-dropdown/test/bal-dropdown.cy.html
@@ -0,0 +1,193 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Basic
+
+
+ 1995
+ 1996
+ 1997
+ 1998
+ 1999
+ 2000
+
+
+
+ 1988
+ 1989
+ 1990
+ 1991
+ 1992
+ 1993
+ 1994
+ 1995
+ 1996
+ 1997
+ 1998
+ 1999
+ 2000
+ 2001
+ 2002
+ 2003
+ 2004
+ 2005
+ 2006
+ 2007
+
+
+ Required
+
+
+ 1995
+ 1996
+ 1997
+ 1998
+ 1999
+ 2000
+
+
+ Multiple
+
+
+
+ Black Widow
+ S.H.I.E.L.D.
+
+
+ Black Panter
+ Wakanda
+
+
+ Iron Man
+ Malibu
+
+
+ Spider Man
+ Queens
+
+
+ Captain America
+ Broklyn
+
+
+ Thor God of Thunder
+ Asgard
+
+
+
+ Form Submit
+
+ Autocomplete
+
+
+
+
+
diff --git a/packages/core/src/components/bal-dropdown/test/bal-dropdown.visual.html b/packages/core/src/components/bal-dropdown/test/bal-dropdown.visual.html
new file mode 100644
index 0000000000..08599004f2
--- /dev/null
+++ b/packages/core/src/components/bal-dropdown/test/bal-dropdown.visual.html
@@ -0,0 +1,211 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Basic
+
+
+ 1988
+ 1989
+ 1990
+ 1991
+ 1992
+ 1993
+ 1994
+ 1995
+ 1996
+ 1997
+ 1998
+ 1999
+ 2000
+ 2001
+ 2002
+ 2003
+ 2004
+ 2005
+ 2006
+ 2007
+
+
+ Long Content
+
+
+
+ Black Widow
+ Black Widow is a 2021 American superhero film based on Marvel Comics featuring the character of the
+ same name.
+
+
+ Black Panter
+ Wakanda
+
+
+ Iron Man
+ Malibu
+
+
+ Spider Man
+ Queens
+
+
+ Captain America
+ Broklyn
+
+
+ Thor God of Thunder
+ Asgard
+
+
+
+ Multiple
+
+
+ 1988
+ 1989
+ 1990
+ 1991
+ 1992
+ 1993
+ 1994
+ 1995
+ 1996
+ 1997
+ 1998
+ 1999
+ 2000
+ 2001
+ 2002
+ 2003
+ 2004
+ 2005
+ 2006
+ 2007
+
+
+ Multiple with Chips
+
+
+ 1988
+ 1989
+ 1990
+ 1991
+ 1992
+ 1993
+ 1994
+ 1995
+ 1996
+ 1997
+ 1998
+ 1999
+ 2000
+ 2001
+ 2002
+ 2003
+ 2004
+ 2005
+ 2006
+ 2007
+
+
+ Clearable
+
+
+ 1988
+ 1989
+ 1990
+ 1991
+ 1992
+
+
+ Loading
+
+
+ 1988
+ 1989
+ 1990
+ 1991
+ 1992
+
+
+ Invalid
+
+
+ 1988
+
+
+ 1988
+
+
+ 1988
+
+
+ Disabled
+
+
+ 1988
+
+
+ 1988
+
+
+ 1988
+
+
+ Form Field
+
+
+ Form Label
+
+
+ 1988
+ 1989
+ 1990
+ 1991
+ 1992
+ 1993
+ 1994
+ 1995
+ 1996
+ 1997
+ 1998
+ 1999
+ 2000
+ 2001
+ 2002
+ 2003
+ 2004
+ 2005
+ 2006
+ 2007
+
+
+
+
+
+
+
+
diff --git a/packages/core/src/components/bal-field/bal-field.tsx b/packages/core/src/components/bal-field/bal-field.tsx
index cf9b7a191c..7af5e7efca 100644
--- a/packages/core/src/components/bal-field/bal-field.tsx
+++ b/packages/core/src/components/bal-field/bal-field.tsx
@@ -27,6 +27,7 @@ export class Field implements ComponentInterface, BalMutationObserver {
'bal-input-stepper',
'bal-input-slider',
'bal-file-upload',
+ 'bal-dropdown',
]
private formElements = [...this.formControlElement, 'bal-field-label', 'bal-field-message']
@@ -131,11 +132,21 @@ export class Field implements ComponentInterface, BalMutationObserver {
return this.isDirectChild(parent)
}
+ private isVisible = (el: HTMLElement): boolean => {
+ if (!el) {
+ return false
+ }
+
+ return el.ariaHidden !== 'true'
+ }
+
private findDirectChild = (selectors: string): BalAriaFormLinking | undefined => {
const element = this.el.querySelector(selectors)
const isDirectChild = this.isDirectChild(element)
if (isDirectChild) {
- return element
+ if (this.isVisible(element)) {
+ return element
+ }
}
return undefined
}
@@ -143,7 +154,7 @@ export class Field implements ComponentInterface, BalMutationObserver {
private findDirectChildren = (selectors: string[]): BalAriaFormLinking[] => {
return selectors
.map(selector => {
- return Array.from(this.el.querySelectorAll(selector)).filter(this.isDirectChild)
+ return Array.from(this.el.querySelectorAll(selector)).filter(this.isDirectChild).filter(this.isVisible)
})
.flat()
}
@@ -158,6 +169,7 @@ export class Field implements ComponentInterface, BalMutationObserver {
'bal-field-control bal-input',
'bal-field-control bal-select',
'bal-field-control bal-input-date',
+ 'bal-field-control bal-dropdown',
'bal-field-control bal-checkbox',
'bal-field-control bal-radio',
'bal-field-control bal-checkbox-group',
diff --git a/packages/core/src/components/bal-option-list/bal-option-list.interfaces.ts b/packages/core/src/components/bal-option-list/bal-option-list.interfaces.ts
new file mode 100644
index 0000000000..035a16effa
--- /dev/null
+++ b/packages/core/src/components/bal-option-list/bal-option-list.interfaces.ts
@@ -0,0 +1,10 @@
+/* eslint-disable no-unused-vars */
+/* eslint-disable @typescript-eslint/no-unused-vars */
+// eslint-disable-next-line @typescript-eslint/triple-slash-reference
+///
+
+namespace BalProps {
+ export type BalOptionListFilter = 'includes' | 'starts-with'
+}
+
+namespace BalEvents {}
diff --git a/packages/core/src/components/bal-option-list/bal-option-list.sass b/packages/core/src/components/bal-option-list/bal-option-list.sass
new file mode 100644
index 0000000000..5e86a15970
--- /dev/null
+++ b/packages/core/src/components/bal-option-list/bal-option-list.sass
@@ -0,0 +1,15 @@
+@import '@baloise/ds-styles/sass/mixins'
+@import './bal-option-list.vars'
+
+// Option List
+// --------------------------------------------------
+.bal-option-list
+ display: block
+ position: relative
+ width: 100%
+ list-style: none
+ margin: 0
+ outline: 0
+ max-height: var(--bal-option-list-max-height)
+ border-radius: var(--bal-radius-normal)
+ overflow: hidden auto
diff --git a/packages/core/src/components/bal-option-list/bal-option-list.tsx b/packages/core/src/components/bal-option-list/bal-option-list.tsx
new file mode 100644
index 0000000000..151e712e1f
--- /dev/null
+++ b/packages/core/src/components/bal-option-list/bal-option-list.tsx
@@ -0,0 +1,537 @@
+import { Component, h, ComponentInterface, Host, Element, Prop, Watch, Method, Listen, State } from '@stencil/core'
+import isNil from 'lodash.isnil'
+import { Attributes, inheritAttributes } from '../../utils/attributes'
+import { BEM } from '../../utils/bem'
+import { raf, waitAfterFramePaint } from '../../utils/helpers'
+import { Loggable, Logger, LogInstance } from '../../utils/log'
+import { includes, startsWith } from '../bal-select/utils/utils'
+import { BalAriaForm, defaultBalAriaForm } from '../../utils/form'
+import { BalOption } from '../../utils/dropdown'
+
+@Component({
+ tag: 'bal-option-list',
+ styleUrl: 'bal-option-list.sass',
+ shadow: false,
+})
+export class OptionList implements ComponentInterface, Loggable {
+ private inputId = `bal-option-list-${balOptionListIds++}`
+ private inheritAttributes: Attributes = {}
+ private focusRaf: number | undefined
+
+ @Element() el!: HTMLElement
+
+ log!: LogInstance
+
+ @State() ariaForm: BalAriaForm = defaultBalAriaForm
+
+ @Logger('bal-option-list')
+ createLogger(log: LogInstance) {
+ this.log = log
+ }
+
+ /**
+ * PUBLIC PROPERTY API
+ * ------------------------------------------------------
+ */
+
+ /**
+ * If `true` the list supports multiple selections
+ */
+ @Prop() multiple = false
+
+ /**
+ * If `true`, the user cannot interact with the option.
+ */
+ @Prop() disabled = false
+
+ /**
+ * If `true`, the user must fill in a value before submitting a form.
+ */
+ @Prop() required = false
+
+ /**
+ * Defines the focused option with his index value
+ */
+ @Prop({ mutable: true }) focusIndex = -1
+
+ /**
+ * Id of the label element to describe this option list
+ */
+ @Prop() labelledby?: string
+
+ /**
+ * Defines the filter logic of the list
+ */
+ @Prop() filter: BalProps.BalOptionListFilter = 'includes'
+
+ /**
+ * Defines the max height of the list element
+ */
+ @Prop() contentHeight?: number = 262
+ @Watch('contentHeight') contentHeightChanged(value?: number) {
+ if (value === undefined) {
+ this.el.style.removeProperty('--bal-option-list-max-height')
+ } else {
+ this.el.style.setProperty('--bal-option-list-max-height', `${value}px`)
+ }
+ }
+
+ /**
+ * LIFECYCLE
+ * ------------------------------------------------------
+ */
+
+ componentDidLoad(): void {
+ this.contentHeightChanged(this.contentHeight)
+ }
+
+ componentWillRender() {
+ this.inheritAttributes = inheritAttributes(this.el, ['aria-multiselectable', 'aria-labelledby'])
+ }
+
+ /**
+ * LISTENERS
+ * ------------------------------------------------------
+ */
+
+ @Listen('balOptionFocus', { passive: true })
+ listenToMouseEnter(ev: BalEvents.BalOptionFocus) {
+ const options = this.options
+ const indexToFocus = this.getOptionIndex(options, ev.detail.value)
+ if (indexToFocus !== undefined) {
+ this.updateFocus(options, indexToFocus)
+ }
+ }
+
+ @Listen('balOptionChange', { passive: false })
+ listenToOptionChange({ detail }: BalEvents.BalOptionFocus) {
+ if (!this.multiple) {
+ this.options.filter(option => option.value !== detail.value).forEach(option => (option.selected = false))
+ }
+ }
+
+ /**
+ * PUBLIC METHODS
+ * ------------------------------------------------------
+ */
+
+ /**
+ * Focus the first visible option in the list
+ * @returns focusIndex
+ */
+ @Method() async focusFirst(): Promise {
+ const options = this.options
+ const indexToFocus = this.getFirstOptionIndex(options)
+ this.updateFocus(options, indexToFocus)
+
+ const option = options[indexToFocus]
+ this.updateScrollTopPosition(option)
+
+ await waitAfterFramePaint()
+ return indexToFocus
+ }
+
+ /**
+ * Focus the last visible option in the list
+ * @returns focusIndex
+ */
+ @Method() async focusLast(): Promise {
+ const options = this.options
+ const indexToFocus = this.getLastOptionIndex(options)
+ this.updateFocus(options, indexToFocus)
+
+ const option = options[indexToFocus]
+ this.updateScrollBottomPosition(option)
+
+ await waitAfterFramePaint()
+ return indexToFocus
+ }
+
+ /**
+ * Focus the next visible option in the list
+ * @returns focusIndex
+ */
+ @Method() async focusNext(): Promise {
+ const options = this.options
+ const indexToFocus = this.getNextOptionIndex(options)
+ this.updateFocus(options, indexToFocus)
+
+ const option = options[indexToFocus]
+ this.updateScrollBottomPosition(option)
+
+ await waitAfterFramePaint()
+ return indexToFocus
+ }
+
+ /**
+ * Focus the previous visible option in the list
+ * @returns focusIndex
+ */
+ @Method() async focusPrevious(): Promise {
+ const options = this.options
+ const indexToFocus = this.getPreviousOptionIndex(options)
+ this.updateFocus(options, indexToFocus)
+
+ const option = options[indexToFocus]
+ this.updateScrollTopPosition(option)
+
+ await waitAfterFramePaint()
+ return indexToFocus
+ }
+
+ /**
+ * Focus the option with the label that starts with the search property
+ * @returns focusIndex
+ */
+ @Method() async focusByLabel(search: string, config: Partial<{ select: boolean }>): Promise {
+ const options = this.options
+ const indexToFocus = this.getOptionIndexByLabel(options, search)
+ this.updateFocus(options, indexToFocus)
+
+ const option = options[indexToFocus]
+ this.updateScrollTopPosition(option)
+
+ await waitAfterFramePaint()
+
+ if (config.select) {
+ await option.select()
+ }
+
+ return indexToFocus
+ }
+
+ /**
+ * Filter the options by the given filter property and hides options
+ * @returns focusIndex
+ */
+ @Method() async filterByContent(search: string): Promise {
+ const options = this.allOptions
+ this.filterOptions(options, search)
+ await waitAfterFramePaint()
+ return this.focusFirst()
+ }
+
+ /**
+ * Shows or hides all options
+ */
+ @Method() async resetHidden(hidden = false): Promise {
+ this.options.forEach(option => (option.hidden = hidden))
+ await waitAfterFramePaint()
+ this.resetFocus()
+ }
+
+ /**
+ * Selects or deselects all options
+ */
+ @Method() async resetSelected(selected = false): Promise {
+ this.options.forEach(option => (option.selected = selected))
+ await waitAfterFramePaint()
+ }
+
+ /**
+ * Updates options
+ */
+ @Method() async updateSelected(values?: string[]): Promise {
+ this.options.forEach(option => (option.selected = values.includes(option.value)))
+ await waitAfterFramePaint()
+ }
+
+ /**
+ * Resets the focus index to pristine and scrolls to the top of the list
+ */
+ @Method() async resetFocus(): Promise {
+ const options = this.options
+ const indexToFocus = -1
+ this.updateFocus(options, indexToFocus)
+ this.scrollTo(0)
+
+ await waitAfterFramePaint()
+ return indexToFocus
+ }
+
+ /**
+ * Returns a list of option values
+ */
+ @Method() async getSelectedValues(): Promise {
+ const options = this.options
+ return options.filter(option => option.selected).map(option => option.value)
+ }
+
+ /**
+ * Returns a list of option labels
+ */
+ @Method() async getSelectedOptions(values?: string[]): Promise {
+ const options = this.options
+ if (values && values.length > 0) {
+ return options.filter(option => values.includes(option.value)).map(option => option)
+ }
+ return options.filter(option => option.selected).map(option => option)
+ }
+
+ /**
+ * Returns a list of options
+ */
+ @Method() async getValues(): Promise {
+ return this.options.map(option => option.value)
+ }
+
+ /**
+ * Returns a list of options
+ */
+ @Method() async getLabels(): Promise {
+ return this.options.map(option => option.label)
+ }
+
+ /**
+ * Returns a list of accessible options
+ */
+ @Method() async getOptions(): Promise {
+ return this.options.filter(o => !o.disabled || !o.hidden)
+ }
+
+ /**
+ * Selects the option with the current focus
+ */
+ @Method() async selectByFocus(): Promise {
+ const options = this.options
+ const option = options[this.focusIndex]
+ if (option) {
+ if (this.multiple) {
+ option.select(!option.selected)
+ } else {
+ option.select(true)
+ }
+ }
+ }
+
+ /**
+ * @internal
+ */
+ @Method()
+ async setAriaForm(ariaForm: BalAriaForm): Promise {
+ this.ariaForm = { ...ariaForm }
+ }
+
+ /**
+ * GETTERS
+ * ------------------------------------------------------
+ */
+
+ private get options(): HTMLBalOptionElement[] {
+ return Array.from(this.el.querySelectorAll('bal-option')).filter(o => !o.hidden)
+ }
+
+ private get allOptions(): HTMLBalOptionElement[] {
+ return Array.from(this.el.querySelectorAll('bal-option'))
+ }
+
+ /**
+ * PRIVATE METHODS
+ * ------------------------------------------------------
+ */
+
+ private filterOptions(options: HTMLBalOptionElement[], search: string): HTMLBalOptionElement[] {
+ const filteredOptions: HTMLBalOptionElement[] = []
+
+ const filter = this.filter === 'includes' ? includes : startsWith
+
+ for (let index = 0; index < options.length; index++) {
+ const option = options[index]
+ const content = option.textContent || ''
+
+ if (filter(content, search)) {
+ filteredOptions.push(option)
+ option.hidden = false
+ } else {
+ option.hidden = true
+ }
+ }
+
+ return filteredOptions
+ }
+
+ private isOptionVisible(option: HTMLBalOptionElement): boolean {
+ const visibleHeight = this.el.clientHeight
+ const topPosition = this.el.scrollTop
+ const bottomPosition = topPosition + visibleHeight
+ const isVisible = topPosition <= option.offsetTop && option.offsetTop + option.clientHeight <= bottomPosition
+ return isVisible
+ }
+
+ private updateScrollTopPosition(option: HTMLBalOptionElement) {
+ if (option) {
+ const isVisible = this.isOptionVisible(option)
+ if (!isVisible) {
+ this.scrollTo(this.getScrollTopForTopPosition(option))
+ }
+ }
+ }
+
+ private updateScrollBottomPosition(option: HTMLBalOptionElement) {
+ if (option) {
+ const isVisible = this.isOptionVisible(option)
+ if (!isVisible) {
+ this.scrollTo(this.getScrollTopForBottomPosition(option))
+ }
+ }
+ }
+
+ private getScrollTopForTopPosition(option: HTMLBalOptionElement): number {
+ const topPosition = option.offsetTop
+ const scrollTop = topPosition
+
+ if (scrollTop < 0) {
+ return 0
+ }
+
+ return scrollTop
+ }
+
+ private getScrollTopForBottomPosition(option: HTMLBalOptionElement): number {
+ const visibleHeight = this.el.clientHeight
+ const bottomPosition = option.offsetTop + option.clientHeight
+ const scrollTop = bottomPosition - visibleHeight
+
+ if (scrollTop < 0) {
+ return 0
+ }
+
+ return scrollTop
+ }
+
+ private async scrollTo(scrollTop: number) {
+ if (scrollTop !== undefined && scrollTop !== null) {
+ if (this.focusRaf !== undefined) {
+ cancelAnimationFrame(this.focusRaf)
+ }
+
+ this.focusRaf = raf(async () => {
+ this.el.scrollTop = scrollTop
+ })
+ }
+ }
+
+ private updateFocus(options: HTMLBalOptionElement[], indexToFocus: number) {
+ this.focusIndex = indexToFocus
+ for (let index = 0; index < options.length; index++) {
+ const option = options[index]
+ option.focused = index === indexToFocus
+ }
+ }
+
+ private getOptionIndex(options: HTMLBalOptionElement[], value: string): number | undefined {
+ for (let index = 0; index < options.length; index++) {
+ const option = options[index]
+ if (option.value === value) {
+ return index
+ }
+ }
+
+ return undefined
+ }
+
+ private getFirstOptionIndex(options: HTMLBalOptionElement[]): number {
+ for (let index = 0; index < options.length; index++) {
+ const option = options[index]
+ if (!option.disabled) {
+ return index
+ }
+ }
+ return this.focusIndex
+ }
+
+ private getNextOptionIndex(options: HTMLBalOptionElement[], index = this.focusIndex): number {
+ if (index < 0) {
+ return this.getFirstOptionIndex(options)
+ }
+
+ const lastIndex = this.getLength(options)
+ let newIndex = index
+ if (index < lastIndex) {
+ newIndex = index + 1
+ if (options[newIndex].disabled) {
+ return this.getNextOptionIndex(options, newIndex)
+ }
+ }
+
+ return newIndex
+ }
+
+ private getPreviousOptionIndex(options: HTMLBalOptionElement[], index = this.focusIndex): number {
+ const firstIndex = this.getFirstOptionIndex(options)
+ if (index <= firstIndex) {
+ return firstIndex
+ }
+
+ let newIndex = index
+ newIndex = index - 1
+ if (options[newIndex].disabled) {
+ return this.getPreviousOptionIndex(options, newIndex)
+ }
+
+ return newIndex
+ }
+
+ private getLastOptionIndex(options: HTMLBalOptionElement[]): number {
+ for (let index = options.length - 1; index >= 0; index--) {
+ const option = options[index]
+ if (!option.disabled) {
+ return index
+ }
+ }
+ return this.focusIndex
+ }
+
+ private getOptionIndexByLabel(options: HTMLBalOptionElement[], label: string): number {
+ if (label === undefined || label === '') {
+ return this.focusIndex
+ }
+
+ const option = options.find(o => startsWith(o.label || '', label))
+ if (!isNil(option) && option.id) {
+ return options.indexOf(option)
+ }
+
+ return this.focusIndex
+ }
+
+ private getLength(options: HTMLBalOptionElement[]): number {
+ const indexes: number[] = options.map((option, index) => (option.disabled ? 0 : index))
+ const length = Math.max(...indexes)
+ return length
+ }
+
+ /**
+ * RENDER
+ * ------------------------------------------------------
+ */
+
+ render() {
+ const block = BEM.block('option-list')
+ const labelledby = this.labelledby || this.ariaForm.labelId
+
+ return (
+
+
+
+
+
+ )
+ }
+}
+
+let balOptionListIds = 0
diff --git a/packages/core/src/components/bal-option-list/bal-option-list.vars.sass b/packages/core/src/components/bal-option-list/bal-option-list.vars.sass
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/core/src/components/bal-option-list/test/bal-option-list.a11y.html b/packages/core/src/components/bal-option-list/test/bal-option-list.a11y.html
new file mode 100644
index 0000000000..befbaafdd3
--- /dev/null
+++ b/packages/core/src/components/bal-option-list/test/bal-option-list.a11y.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Year list
+
+ 1991
+ 1992
+ 1993
+ 1994
+
+
+
+
+
+
diff --git a/packages/core/src/components/bal-option-list/test/bal-option-list.visual.html b/packages/core/src/components/bal-option-list/test/bal-option-list.visual.html
new file mode 100644
index 0000000000..6c9febb944
--- /dev/null
+++ b/packages/core/src/components/bal-option-list/test/bal-option-list.visual.html
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1988
+ 1989
+ 1990
+ 1991
+ 1992
+ 1993
+ 1994
+ 1995
+ 1996
+ 1997
+ 1998
+ 1999
+ 2000
+ 2001
+ 2002
+ 2003
+ 2004
+ 2005
+ 2006
+ 2007
+
+
+ Focus First
+ Focus Last
+ Focus Next
+ Focus Previous
+ Focus Label
+ Filter Label
+
+
+
+
+
+
+
diff --git a/packages/core/src/components/bal-option/bal-option.interfaces.ts b/packages/core/src/components/bal-option/bal-option.interfaces.ts
new file mode 100644
index 0000000000..b3f9dbcdd5
--- /dev/null
+++ b/packages/core/src/components/bal-option/bal-option.interfaces.ts
@@ -0,0 +1,28 @@
+/* eslint-disable no-unused-vars */
+/* eslint-disable @typescript-eslint/no-unused-vars */
+// eslint-disable-next-line @typescript-eslint/triple-slash-reference
+///
+
+namespace BalProps {}
+
+namespace BalEvents {
+ export interface BalOptionCustomEvent extends CustomEvent {
+ detail: T
+ target: HTMLBalOptionElement
+ }
+
+ export interface BalOption {
+ value: string
+ label: string
+ selected: boolean
+ }
+
+ export type BalOptionFocusDetail = BalOption
+ export type BalOptionFocus = BalOptionCustomEvent
+
+ export type BalOptionBlurDetail = FocusEvent
+ export type BalOptionBlur = BalOptionCustomEvent
+
+ export type BalOptionChangeDetail = BalOption
+ export type BalOptionChange = BalOptionCustomEvent
+}
diff --git a/packages/core/src/components/bal-option/bal-option.sass b/packages/core/src/components/bal-option/bal-option.sass
new file mode 100644
index 0000000000..6e56632c6a
--- /dev/null
+++ b/packages/core/src/components/bal-option/bal-option.sass
@@ -0,0 +1,121 @@
+@import '@baloise/ds-styles/sass/mixins'
+@import './bal-option.vars'
+
+// Option
+// --------------------------------------------------
+.bal-option
+ font-size: 16px
+ +unselectable
+ position: relative
+ display: block
+ list-style: none
+ cursor: pointer
+ border-bottom-width: var(--bal-option-border-width)
+ border-bottom-color: var(--bal-option-border-color)
+ border-bottom-style: solid
+ background: var(--bal-option-background)
+ padding-left: var(--bal-option-padding-x)
+ padding-right: var(--bal-option-padding-x)
+ min-height: var(--bal-option-min-height)
+ & > bal-stack
+ min-height: var(--bal-option-min-height)
+ &:last-child
+ border-bottom: none
+ &::-moz-focus-inner
+ border: none
+ &--hidden
+ display: none
+ visibility: hidden
+
+// Option Content
+// --------------------------------------------------
+
+.bal-option__inner
+ font-family: var(--bal-option-font-family)
+ font-weight: var(--bal-option-font-weight)
+ line-height: var(--bal-option-line-height)
+ word-break: break-word
+ white-space: normal
+ hyphens: auto
+ overflow: hidden
+ text-overflow: ellipsis
+ white-space: nowrap
+ flex: 1
+
+.bal-option__inner--multiline
+ white-space: normal
+
+// Focused Option
+// --------------------------------------------------
+.bal-option--focused
+ background: var(--bal-option-background-hovered)
+
+.bal-option
+ +hover
+ &:hover
+ background: var(--bal-option-background-hovered)
+ color: var(--bal-option-text-hovered)
+ &:active
+ background: var(--bal-option-background-hovered)
+ color: var(--bal-option-text-pressed)
+
+// Selected Option
+// --------------------------------------------------
+.bal-option--selected
+ background: var(--bal-option-background-selected)
+ +hover
+ &:hover
+ background: var(--bal-option-background-selected-hovered)
+ &:active
+ background: var(--bal-option-background-selected-hovered)
+
+.bal-option--selected .bal-option__inner
+ font-weight: var(--bal-font-weight-bold)
+
+.bal-option--selected.bal-option--focused
+ background: var(--bal-option-background-selected-hovered)
+ .bal-option__inner
+ font-weight: var(--bal-font-weight-bold)
+
+// Invalid Option
+// --------------------------------------------------
+.bal-option--invalid
+ background: var(--bal-option-background-invalid)
+ +hover
+ &:hover
+ background: var(--bal-option-background-invalid-hovered)
+ &:active
+ background: var(--bal-option-background-invalid-hovered)
+
+.bal-option--invalid.bal-option--focused
+ background: var(--bal-option-background-invalid-hovered)
+
+.bal-option--invalid .bal-option__inner
+ color: var(--bal-color-text-danger-hovered)
+
++hover
+ .bal-option--invalid:hover .bal-option__inner
+ color: var(--bal-color-text-danger-hovered)
+
+.bal-option--invalid:active .bal-option__inner
+ color: var(--bal-color-text-danger-pressed)
+
+// Disbaled Option
+// --------------------------------------------------
+.bal-option--disabled,
+.bal-option--disabled.bal-option--focused
+ background: var(--bal-option-background) !important
+ color: var(--bal-option-text-disabled) !important
+ cursor: default !important
+ &:hover
+ background: var(--bal-option-background) !important
+
+// Adjust checkbox size
+// --------------------------------------------------
+.bal-option
+ --bal-radio-checkbox-symbol-size: 1.125rem
+ --bal-radio-checkbox-symbol-width: calc(0.375rem - 1px)
+ --bal-radio-checkbox-symbol-height: calc(0.656rem - 1px)
+ --bal-radio-checkbox-symbol-left: calc(0.375rem + 1px)
+ --bal-radio-checkbox-symbol-margin-top: 0.188rem
+ --bal-radio-checkbox-label-min-height: 1.125rem
diff --git a/packages/core/src/components/bal-option/bal-option.tsx b/packages/core/src/components/bal-option/bal-option.tsx
new file mode 100644
index 0000000000..2540a2edac
--- /dev/null
+++ b/packages/core/src/components/bal-option/bal-option.tsx
@@ -0,0 +1,245 @@
+import {
+ Component,
+ h,
+ ComponentInterface,
+ Host,
+ Element,
+ Prop,
+ Listen,
+ Event,
+ EventEmitter,
+ Method,
+ State,
+} from '@stencil/core'
+import { BEM } from '../../utils/bem'
+import { Loggable, Logger, LogInstance } from '../../utils/log'
+import { ariaBooleanToString } from '../../utils/aria'
+import { BalElementStateInfo, BalElementStateObserver, ListenToElementStates } from '../../utils/element-states'
+import { stopEventBubbling } from '../../utils/form-input'
+import { BalElementStateListener } from '../../utils/element-states/element-states.listener'
+import { BalOption } from '../../utils/dropdown'
+
+@Component({
+ tag: 'bal-option',
+ styleUrl: 'bal-option.sass',
+ shadow: false,
+})
+export class Option implements ComponentInterface, Loggable, BalElementStateObserver, BalOption {
+ private inputId = `bal-option-${balOptionIds++}`
+
+ @Element() el!: HTMLElement
+
+ log!: LogInstance
+
+ @Logger('bal-option')
+ createLogger(log: LogInstance) {
+ this.log = log
+ }
+
+ @State() checkbox = false
+ @State() interactionState: BalElementStateInfo = BalElementStateListener.DefaultState
+
+ /**
+ * PUBLIC PROPERTY API
+ * ------------------------------------------------------
+ */
+
+ /**
+ * Label will be shown in the input element when it got selected
+ */
+ @Prop() label = ''
+
+ /**
+ * The value of the select option. This value will be returned by the parent `` element.
+ */
+ @Prop() value = ''
+
+ /**
+ * If `true`, the user cannot interact with the option.
+ */
+ @Prop() disabled = false
+
+ /**
+ * If `true`, the option can present in more than one line.
+ */
+ @Prop() multiline = false
+
+ /**
+ * If `true`, the option is shown in red.
+ */
+ @Prop() invalid = false
+
+ /**
+ * If `true`, the option is selected.
+ */
+ @Prop({ mutable: true }) selected = false
+
+ /**
+ * If `true`, the option is focused.
+ */
+ @Prop({ mutable: true }) focused = false
+
+ /**
+ * If `true`, the option is hidden.
+ */
+ @Prop() hidden = false
+
+ /**
+ * Emitted when the option gets selected or unselected
+ */
+ @Event() balOptionChange!: EventEmitter
+
+ /**
+ * @internal
+ * Emitted when a option gets focused.
+ */
+ @Event() balOptionFocus!: EventEmitter
+
+ /**
+ * LIFECYCLE
+ * ------------------------------------------------------
+ */
+
+ componentWillRender(): void | Promise {
+ if (this.el) {
+ const optionListEl = this.el.closest('bal-option-list')
+ if (optionListEl) {
+ this.checkbox = !!optionListEl.multiple
+ }
+ }
+ }
+
+ /**
+ * LISTENERS
+ * ------------------------------------------------------
+ */
+
+ @Listen('mouseenter')
+ listenToMouseEnter() {
+ const { label, value, selected, disabled, hidden } = this
+ if (!hidden && !disabled) {
+ this.balOptionFocus.emit({ label, value, selected })
+ }
+ }
+
+ @ListenToElementStates()
+ elementStateListener(info: BalElementStateInfo): void {
+ this.interactionChildElements.forEach(element => {
+ element.hovered = info.hovered
+ element.pressed = info.pressed
+ })
+ }
+
+ /**
+ * PUBLIC METHODS
+ * ------------------------------------------------------
+ */
+
+ /**
+ * Selects or deselects the option and informs other components
+ */
+ @Method() async select(selected = true): Promise {
+ this.selected = selected
+ const { label, value } = this
+ this.balOptionChange.emit({ label, value, selected })
+ }
+
+ /**
+ * PRIVATE METHODS
+ * ------------------------------------------------------
+ */
+
+ private get interactionChildElements(): Array {
+ return Array.from(this.el.querySelectorAll('bal-checkbox'))
+ }
+
+ /**
+ * EVENT BINDING
+ * ------------------------------------------------------
+ */
+
+ private onClick = (ev: MouseEvent) => {
+ const listEl = this.el.closest('bal-option-list')
+ if (this.disabled || (listEl && listEl.disabled)) {
+ stopEventBubbling(ev)
+ } else {
+ if (listEl && listEl.required && !listEl.multiple) {
+ if (!this.selected) {
+ this.select(true)
+ }
+ } else {
+ this.select(!this.selected)
+ }
+ }
+ }
+
+ /**
+ * RENDER
+ * ------------------------------------------------------
+ */
+
+ render() {
+ const block = BEM.block('option')
+
+ return (
+
+
+ {this.checkbox ? (
+
+ ) : (
+ ''
+ )}
+
+
+
+ {this.selected && !this.checkbox ? (
+
+ ) : (
+ ''
+ )}
+
+
+ )
+ }
+}
+
+let balOptionIds = 0
diff --git a/packages/core/src/components/bal-option/bal-option.vars.sass b/packages/core/src/components/bal-option/bal-option.vars.sass
new file mode 100644
index 0000000000..46994a11ce
--- /dev/null
+++ b/packages/core/src/components/bal-option/bal-option.vars.sass
@@ -0,0 +1,42 @@
+/**
+ * @prop --bal-option-padding-x: TBD
+ * @prop --bal-option-min-height: TBD
+ * @prop --bal-option-background: TBD
+ * @prop --bal-option-background-hovered: TBD
+ * @prop --bal-option-background-selected: TBD
+ * @prop --bal-option-background-selected-hovered: TBD
+ * @prop --bal-option-background-invalid: TBD
+ * @prop --bal-option-background-invalid-hovered: TBD
+ * @prop --bal-option-border-width: TBD
+ * @prop --bal-option-border-color: TBD
+ * @prop --bal-option-font-family: TBD
+ * @prop --bal-option-font-weight: TBD
+ * @prop --bal-option-line-height: TBD
+ * @prop --bal-option-text-hovered: TBD
+ * @prop --bal-option-text-pressed: TBD
+ * @prop --bal-option-text-disabled: TBD
+ */
+
+:root
+ --bal-option-padding-x: var(--bal-space-small)
+ --bal-option-min-height: 1.5rem
+ //
+ // background colors
+ --bal-option-background: var(--bal-color-white)
+ --bal-option-background-hovered: var(--bal-color-grey-2)
+ --bal-option-background-selected: var(--bal-color-primary-1)
+ --bal-option-background-selected-hovered: #dddfeb
+ --bal-option-background-invalid: var(--bal-color-danger-1)
+ --bal-option-background-invalid-hovered: #fbe0de
+ //
+ // border
+ --bal-option-border-width: var(--bal-border-width-normal)
+ --bal-option-border-color: var(--bal-color-grey-2)
+ //
+ // typography
+ --bal-option-font-family: var(--bal-font-family-text)
+ --bal-option-font-weight: var(--bal-font-weight-regular)
+ --bal-option-line-height: 1.125rem
+ --bal-option-text-hovered: var(--bal-color-text-primary-hovered)
+ --bal-option-text-pressed: var(--bal-color-text-primary-pressed)
+ --bal-option-text-disabled: var(--bal-color-text-grey-light)
diff --git a/packages/core/src/components/bal-option/test/bal-option.a11y.html b/packages/core/src/components/bal-option/test/bal-option.a11y.html
new file mode 100644
index 0000000000..1c466b50de
--- /dev/null
+++ b/packages/core/src/components/bal-option/test/bal-option.a11y.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Black Widow
+ S.H.I.E.L.D.
+
+ Focused
+ Selected
+ Invalid
+ Disabled
+
+
+
+
+
diff --git a/packages/core/src/components/bal-option/test/bal-option.visual.html b/packages/core/src/components/bal-option/test/bal-option.visual.html
new file mode 100644
index 0000000000..52efecf52e
--- /dev/null
+++ b/packages/core/src/components/bal-option/test/bal-option.visual.html
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Black Widow
+ S.H.I.E.L.D.
+
+
+ Black Panter
+ Wakanda
+
+ Lorem ipsum dolor, sit amet consectetur adipisicing elit. Ab perferendis porro est non esse quibusdam,
+ facere vel minus quam unde nisi vitae provident maxime maiores inventore amet culpa illum nam.
+ Lorem ipsum dolor, sit amet consectetur adipisicing elit. Ab perferendis porro est non esse quibusdam,
+ facere vel minus quam unde nisi vitae provident maxime maiores inventore amet culpa illum nam.
+
+
+ Default
+ Focused
+ Selected
+ Disabled
+ Invalid
+
+
+
+
+ Default
+ Focused
+ Selected
+ Disabled
+ Invalid
+
+
+
+
+
+
diff --git a/packages/core/src/components/bal-select/bal-select.tsx b/packages/core/src/components/bal-select/bal-select.tsx
index 63404ed4c1..803fe208eb 100644
--- a/packages/core/src/components/bal-select/bal-select.tsx
+++ b/packages/core/src/components/bal-select/bal-select.tsx
@@ -402,7 +402,7 @@ export class Select implements ComponentInterface, Loggable, BalAriaFormLinking
}
/**
- * Sets the focus on the input element
+ * Returns the value of the component
*/
@Method()
async getValue() {
@@ -1101,7 +1101,7 @@ export class Select implements ComponentInterface, Loggable, BalAriaFormLinking
{this.optionArray.map((option: BalOptionController, index: number) => (
Form-Reset
diff --git a/packages/core/src/components/bal-spinner/bal-spinner.interfaces.ts b/packages/core/src/components/bal-spinner/bal-spinner.interfaces.ts
new file mode 100644
index 0000000000..7c0e4a3a68
--- /dev/null
+++ b/packages/core/src/components/bal-spinner/bal-spinner.interfaces.ts
@@ -0,0 +1,11 @@
+/* eslint-disable no-unused-vars */
+/* eslint-disable @typescript-eslint/no-unused-vars */
+// eslint-disable-next-line @typescript-eslint/triple-slash-reference
+///
+
+namespace BalProps {
+ export type BalSpinnerColor = 'blue' | 'white'
+ export type BalSpinnerVariation = 'logo' | 'circle'
+}
+
+namespace BalEvents {}
diff --git a/packages/core/src/components/bal-spinner/bal-spinner.sass b/packages/core/src/components/bal-spinner/bal-spinner.sass
index 53b8fc8845..350b788961 100644
--- a/packages/core/src/components/bal-spinner/bal-spinner.sass
+++ b/packages/core/src/components/bal-spinner/bal-spinner.sass
@@ -1,10 +1,44 @@
@import '@baloise/ds-styles/sass/mixins'
+// Spinner
+// --------------------------------------------------
+
bal-spinner,
.bal-spinner
text-align: center
display: flex
justify-content: center
align-content: center
+ width: 4rem
svg
transform: unset !important
+
+.bal-spinner--small
+ width: 2rem
+
+// Circle
+// --------------------------------------------------
+
+.bal-spinner--circle
+ margin: auto
+ border-width: 0.25rem
+ border-style: solid
+ border-color: var(--bal-color-grey)
+ border-radius: 50%
+ border-top-color: var(--bal-color-primary)
+ width: 1.5rem
+ height: 1.5rem
+
+.bal-spinner--circle.bal-spinner--small
+ width: 1.125rem
+ height: 1.125rem
+ border-width: 0.2rem
+
+.bal-spinner--circle.bal-spinner--animated
+ animation: spinner 1.6s linear infinite
+
+@keyframes spinner
+ 0%
+ transform: rotate(0deg)
+ 100%
+ transform: rotate(360deg)
diff --git a/packages/core/src/components/bal-spinner/bal-spinner.tsx b/packages/core/src/components/bal-spinner/bal-spinner.tsx
index e81a476b34..013b7ab2b2 100644
--- a/packages/core/src/components/bal-spinner/bal-spinner.tsx
+++ b/packages/core/src/components/bal-spinner/bal-spinner.tsx
@@ -1,8 +1,10 @@
-import { Component, h, Host, Prop, Element, Watch, ComponentInterface } from '@stencil/core'
+import { Component, h, Host, Prop, Element, Watch, ComponentInterface, State } from '@stencil/core'
import type { AnimationItem } from 'lottie-web/build/player/lottie_light_html'
import { rIC } from '../../utils/helpers'
import { Loggable, Logger, LogInstance } from '../../utils/log'
import { raf } from '../../utils/helpers'
+import { BEM } from '../../utils/bem'
+import { BalConfigObserver, BalConfigState, ListenToConfig, defaultConfig } from '../../utils/config'
type SpinnerAnimationFunction = (el: HTMLElement, color: string) => AnimationItem
@@ -10,13 +12,15 @@ type SpinnerAnimationFunction = (el: HTMLElement, color: string) => AnimationIte
tag: 'bal-spinner',
styleUrl: 'bal-spinner.sass',
})
-export class Spinner implements ComponentInterface, Loggable {
+export class Spinner implements ComponentInterface, Loggable, BalConfigObserver {
private animationItem!: AnimationItem
private animationFunction?: SpinnerAnimationFunction
private currentRaf: number | undefined
log!: LogInstance
+ @State() animated = defaultConfig.animated
+
@Logger('bal-spinner')
createLogger(log: LogInstance) {
this.log = log
@@ -52,20 +56,39 @@ export class Spinner implements ComponentInterface, Loggable {
/**
* Defines the color of the spinner.
*/
- @Prop() color: 'blue' | 'white' = 'blue'
+ @Prop() color: BalProps.BalSpinnerColor = 'blue'
/**
* If `true` the component is smaller
*/
@Prop() small = false
+ /**
+ * Defines the look of the spinner
+ */
+ @Prop() variation: BalProps.BalSpinnerVariation = 'logo'
+ @Watch('variation')
+ variationWatcher(newValue: BalProps.BalSpinnerVariation, oldValue: BalProps.BalSpinnerVariation) {
+ if (newValue !== oldValue) {
+ if (this.variation === 'circle') {
+ this.destroy()
+ } else {
+ this.animate()
+ }
+ }
+ }
+
/**
* LIFECYCLE
* ------------------------------------------------------
*/
componentDidLoad() {
- this.animate()
+ if (this.variation === 'logo') {
+ this.animate()
+ } else {
+ this.destroy()
+ }
}
disconnectedCallback() {
@@ -74,12 +97,30 @@ export class Spinner implements ComponentInterface, Loggable {
}
}
+ /**
+ * LISTENERS
+ * ------------------------------------------------------
+ */
+
+ @ListenToConfig()
+ configChanged(state: BalConfigState): void {
+ this.animated = state.animated
+ if (state.animated === false) {
+ this.destroy()
+ }
+ }
+
/**
* PRIVATE METHODS
* ------------------------------------------------------
*/
private animate = async () => {
+ if (!this.animated) {
+ this.destroy()
+ return
+ }
+
await this.load()
if (this.currentRaf !== undefined) {
@@ -104,6 +145,10 @@ export class Spinner implements ComponentInterface, Loggable {
}
private shouldAnimate = () => {
+ if (this.variation !== 'logo') {
+ return false
+ }
+
if (typeof (window as any) === 'undefined') {
return false
}
@@ -147,6 +192,19 @@ export class Spinner implements ComponentInterface, Loggable {
*/
render() {
- return
+ const block = BEM.block('spinner')
+
+ return (
+
+ )
}
}
diff --git a/packages/core/src/components/bal-spinner/test/bal-spinner.cy.html b/packages/core/src/components/bal-spinner/test/bal-spinner.cy.html
index fd6f97ad75..da45dd65f7 100644
--- a/packages/core/src/components/bal-spinner/test/bal-spinner.cy.html
+++ b/packages/core/src/components/bal-spinner/test/bal-spinner.cy.html
@@ -23,10 +23,16 @@ Small
Inverted