@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
254 lines (233 loc) • 8.52 kB
text/typescript
import { PktElement } from '@/base-elements/element'
import { PktSlotController } from '@/controllers/pkt-slot-controller'
import { ref, Ref, createRef } from 'lit/directives/ref.js'
import { ifDefined } from 'lit/directives/if-defined.js'
import { html, nothing, PropertyValues } from 'lit'
import { unsafeHTML } from 'lit/directives/unsafe-html.js'
import { classMap } from 'lit/directives/class-map.js'
import { customElement, property } from 'lit/decorators.js'
import { ElementProps } from '@/types/typeUtils'
import { uuidish } from '@/utils/stringutils'
import specs from 'componentSpecs/input-wrapper.json'
import '@/components/helptext'
import '@/components/icon'
type TCounterPosition = 'top' | 'bottom'
type Props = ElementProps<
PktInputWrapper,
| 'forId'
| 'label'
| 'helptext'
| 'helptextDropdown'
| 'helptextDropdownButton'
| 'counter'
| 'counterCurrent'
| 'counterMaxLength'
| 'optionalTag'
| 'optionalText'
| 'requiredTag'
| 'requiredText'
| 'tagText'
| 'hasError'
| 'errorMessage'
| 'disabled'
| 'inline'
| 'ariaDescribedby'
| 'hasFieldset'
| 'useWrapper'
| 'role'
>
// TODO: Trekk denne logikken ut i utils, så den kan gjenbrukes andre steder
type Booleanish = boolean | 'true' | 'false'
const booleanishConverter = {
fromAttribute(value: string | boolean | null): boolean {
// Accept false, "false", undefined, null as false
if (value === null || value === undefined) return false
if (value === '' || value === 'true' || value === true) return true
if (value === 'false' || value === false) return false
return Boolean(value)
},
toAttribute(value: boolean): string | null {
// Always reflect as "true" or "false"
return value ? 'true' : 'false'
},
}
('pkt-input-wrapper')
export class PktInputWrapper extends PktElement<Props> {
defaultSlot: Ref<HTMLElement> = createRef()
helptextSlot: Ref<HTMLElement> = createRef()
constructor() {
super()
this.slotController = new PktSlotController(this, this.defaultSlot, this.helptextSlot)
}
/**
* Element attributes
*/
({ type: String }) forId: string = uuidish()
({ type: String }) label: string = ''
({ type: String }) helptext: string | null = null
({ type: String }) helptextDropdown: string | null = null
({ type: String }) helptextDropdownButton: string | null = null
({ type: Boolean }) counter: boolean = specs.props.counter.default
({ type: Number }) counterCurrent: number = 0
({ type: Number }) counterMaxLength: number = 0
({ type: String }) counterError: string | null = null
({ type: String, reflect: false }) counterPosition: TCounterPosition = 'bottom'
({ type: Boolean }) optionalTag: boolean = specs.props.optionalTag.default
({ type: String }) optionalText: string = specs.props.optionalText.default
({ type: Boolean }) requiredTag: boolean = specs.props.requiredTag.default
({ type: String }) requiredText: string = specs.props.requiredText.default
({ type: String }) tagText: string | null = null
({ type: Boolean }) hasError: boolean = specs.props.hasError.default
({ type: String }) errorMessage: string = ''
({ type: Boolean }) disabled: boolean = specs.props.disabled.default
({ type: Boolean }) inline: boolean = specs.props.inline.default
({ type: String }) ariaDescribedby: string | undefined = undefined
({ type: Boolean }) hasFieldset: boolean = specs.props.hasFieldset.default
({ type: String, reflect: true }) role: string | null = 'group'
({ type: Boolean, reflect: true, converter: booleanishConverter })
useWrapper: Booleanish = specs.props.useWrapper.default
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties)
}
render() {
const classes = {
'pkt-inputwrapper': true,
'pkt-inputwrapper--error': this.hasError,
'pkt-inputwrapper--disabled': this.disabled,
'pkt-inputwrapper--inline': this.inline,
}
const tagClasses = 'pkt-tag pkt-tag--small pkt-tag--thin-text'
const describedBy = this.ariaDescribedby
? this.ariaDescribedby
: this.helptext
? `${this.forId}-helptext`
: nothing
const tagElement = () => {
return html`
${this.tagText
? html`<span class=${tagClasses + ' pkt-tag--gray'}>${this.tagText}</span>`
: nothing}
${this.optionalTag
? html`<span class=${tagClasses + ' pkt-tag--blue-light'}>${this.optionalText}</span>`
: nothing}
${this.requiredTag
? html`<span class=${tagClasses + ' pkt-tag--beige'}>${this.requiredText}</span>`
: nothing}
`
}
const labelElement = () => {
if (this.useWrapper) {
if (this.hasFieldset) {
return html`<legend
class="pkt-inputwrapper__legend"
id="${this.forId}-label"
@click=${this.handleLabelClick}
>
${this.label} ${tagElement()}
</legend>`
} else {
return html`<label
class="pkt-inputwrapper__label"
for="${this.forId}"
aria-describedby="${describedBy}"
id="${this.forId}-label"
@click=${this.handleLabelClick}
>${this.label}${tagElement()}</label
>`
}
} else {
return html`<label
for="${this.forId}"
class="pkt-sr-only"
aria-describedby="${describedBy}"
id="${this.forId}-label"
>
${this.label}
</label>`
}
}
const helptextElement = () => {
return html`
<pkt-helptext
class="${ifDefined(!this.useWrapper ? 'pkt-hide' : undefined)}"
.forId=${this.forId}
.helptext=${this.helptext}
.helptextDropdown=${this.helptextDropdown}
.helptextDropdownButton=${this.helptextDropdownButton ||
specs.props.helptextDropdownButton.default}
@toggleHelpText=${(e: CustomEvent) => {
this.toggleDropdown(e)
}}
${ref(this.helptextSlot)}
name="helptext"
></pkt-helptext>
`
}
const counterElement = () => {
if (this.counter) {
return html`<div class="pkt-input__counter" aria-live="polite" aria-atomic="true">
${this.counterError ? this.counterError : nothing} ${this.counterCurrent || 0}
${this.counterMaxLength ? `/${this.counterMaxLength}` : nothing}
</div>`
} else {
return nothing
}
}
const errorElement = () => {
if (this.hasError && this.errorMessage) {
return html`<div
role="alert"
class="pkt-alert pkt-alert--error pkt-alert--compact"
aria-live="assertive"
aria-atomic="true"
id="${this.forId}-error"
>
<pkt-icon name="alert-error" class="pkt-alert__icon"></pkt-icon>
<div class="pkt-alert__text">${unsafeHTML(this.errorMessage)}</div>
</div>`
} else {
return nothing
}
}
const inputContent = () => {
//prettier-ignore
return html`
${labelElement()}
${helptextElement()}
${this.counterPosition === 'top' ? counterElement() : nothing}
<div class="pkt-contents" ${ref(this.defaultSlot)}></div>
${this.counterPosition === 'bottom' ? counterElement() : nothing}
${errorElement()}
`
}
const wrapperElement = () => {
return this.hasFieldset
? html`<fieldset class="pkt-inputwrapper__fieldset" aria-describedby="${describedBy}">
${inputContent()}
</fieldset>`
: html`<div class="pkt-inputwrapper__fieldset">${inputContent()}</div>`
}
return html`<div class=${classMap(classes)}>${wrapperElement()}</div> `
}
private toggleDropdown(e: CustomEvent) {
this.dispatchEvent(
new CustomEvent('toggleHelpText', {
bubbles: false,
detail: { isOpen: e.detail.isOpen },
}),
)
}
private handleLabelClick(e: MouseEvent) {
if (this.disabled) {
e.preventDefault()
e.stopImmediatePropagation()
}
this.dispatchEvent(
new CustomEvent('labelClick', {
bubbles: true,
composed: true,
detail: 'label clicked',
}),
)
}
}