@ks-digital/designsystem-angular 0.0.1-alpha.23 → 0.0.1-alpha.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.storybook/customTheme.ts +15 -0
- package/.storybook/default-args.ts +18 -0
- package/.storybook/main.ts +27 -0
- package/.storybook/manager.ts +10 -0
- package/.storybook/preview-head.html +16 -0
- package/.storybook/preview.ts +70 -0
- package/.storybook/themes.ts +9 -0
- package/.storybook/tsconfig.json +16 -0
- package/.storybook/vite.config.mts +5 -0
- package/README.md +3 -3
- package/eslint.config.mjs +28 -0
- package/ng-package.json +9 -0
- package/package.json +18 -27
- package/project.json +81 -0
- package/src/components/alert/alert.mdx +46 -0
- package/src/components/alert/alert.spec.ts +33 -0
- package/src/components/alert/alert.stories.ts +138 -0
- package/src/components/alert/alert.ts +46 -0
- package/src/components/alert/index.ts +1 -0
- package/src/components/button/button.mdx +40 -0
- package/src/components/button/button.spec.ts +86 -0
- package/src/components/button/button.stories.ts +123 -0
- package/src/components/button/button.ts +60 -0
- package/src/components/button/index.ts +1 -0
- package/src/components/card/card-block.ts +10 -0
- package/src/components/card/card.mdx +100 -0
- package/src/components/card/card.spec.ts +70 -0
- package/src/components/card/card.stories.ts +101 -0
- package/src/components/card/card.ts +44 -0
- package/src/components/card/index.ts +2 -0
- package/src/components/checkbox/README.md +13 -0
- package/src/components/checkbox/checkbox.mdx +50 -0
- package/src/components/checkbox/checkbox.spec.ts +21 -0
- package/src/components/checkbox/checkbox.stories.ts +182 -0
- package/src/components/checkbox/index.ts +0 -0
- package/src/components/colors.ts +36 -0
- package/src/components/common-inputs.ts +30 -0
- package/src/components/details/controlled-details.ts +63 -0
- package/src/components/details/details-content.ts +7 -0
- package/src/components/details/details-summary.ts +7 -0
- package/src/components/details/details.mdx +89 -0
- package/src/components/details/details.spec.ts +56 -0
- package/src/components/details/details.stories.ts +129 -0
- package/src/components/details/details.ts +69 -0
- package/src/components/details/index.ts +3 -0
- package/src/components/field/field-counter.ts +56 -0
- package/src/components/field/field-description.ts +10 -0
- package/src/components/field/field-error.ts +13 -0
- package/src/components/field/field-observer.ts +121 -0
- package/src/components/field/field-state.ts +21 -0
- package/src/components/field/field.mdx +40 -0
- package/src/components/field/field.spec.ts +131 -0
- package/src/components/field/field.stories.ts +98 -0
- package/src/components/field/field.ts +70 -0
- package/src/components/field/index.ts +3 -0
- package/src/components/fieldset/fieldset-description.ts +8 -0
- package/src/components/fieldset/fieldset-legend.ts +11 -0
- package/src/components/fieldset/fieldset.spec.ts +80 -0
- package/src/components/fieldset/fieldset.ts +11 -0
- package/src/components/fieldset/index.ts +3 -0
- package/src/components/input/index.ts +1 -0
- package/src/components/input/input.mdx +11 -0
- package/src/components/input/input.spec.ts +25 -0
- package/src/components/input/input.stories.ts +72 -0
- package/src/components/input/input.ts +67 -0
- package/src/components/label/index.ts +1 -0
- package/src/components/label/label.ts +17 -0
- package/src/components/paragraph/index.ts +1 -0
- package/src/components/paragraph/paragraph.ts +10 -0
- package/src/components/popover/controlled-popover.ts +62 -0
- package/src/components/popover/index.ts +1 -0
- package/src/components/popover/popover.mdx +81 -0
- package/src/components/popover/popover.spec.ts +143 -0
- package/src/components/popover/popover.stories.ts +63 -0
- package/src/components/popover/popover.ts +186 -0
- package/src/components/radio/radio.mdx +117 -0
- package/src/components/radio/radio.stories.ts +226 -0
- package/src/components/search/index.ts +4 -0
- package/src/components/search/search-button.ts +35 -0
- package/src/components/search/search-clear.ts +57 -0
- package/src/components/search/search-input.ts +18 -0
- package/src/components/search/search.mdx +56 -0
- package/src/components/search/search.spec.ts +48 -0
- package/src/components/search/search.stories.ts +205 -0
- package/src/components/search/search.ts +50 -0
- package/src/components/spinner/index.ts +1 -0
- package/src/components/spinner/spinner.mdx +24 -0
- package/src/components/spinner/spinner.spec.ts +13 -0
- package/src/components/spinner/spinner.stories.ts +54 -0
- package/src/components/spinner/spinner.ts +62 -0
- package/src/components/switch/switch.mdx +82 -0
- package/src/components/switch/switch.stories.ts +94 -0
- package/src/components/textarea/textarea.mdx +14 -0
- package/src/components/textarea/textarea.stories.ts +52 -0
- package/src/components/validation-message/index.ts +1 -0
- package/src/components/validation-message/validation-message.ts +11 -0
- package/src/index.ts +14 -0
- package/src/test-setup.ts +12 -0
- package/src/utils/log-if-devmode.ts +13 -0
- package/src/utils/random-id.ts +3 -0
- package/tsconfig.json +34 -0
- package/tsconfig.lib.json +28 -0
- package/tsconfig.lib.prod.json +9 -0
- package/tsconfig.spec.json +30 -0
- package/vite.config.mts +35 -0
- package/dist/README.md +0 -55
- package/dist/fesm2022/ks-digital-designsystem-angular.mjs +0 -1068
- package/dist/fesm2022/ks-digital-designsystem-angular.mjs.map +0 -1
- package/dist/index.d.ts +0 -315
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Component,
|
|
3
|
+
CUSTOM_ELEMENTS_SCHEMA,
|
|
4
|
+
ElementRef,
|
|
5
|
+
input,
|
|
6
|
+
output,
|
|
7
|
+
viewChild,
|
|
8
|
+
} from '@angular/core'
|
|
9
|
+
import '@u-elements/u-details'
|
|
10
|
+
|
|
11
|
+
@Component({
|
|
12
|
+
selector: 'ksd-details',
|
|
13
|
+
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
|
14
|
+
template: `
|
|
15
|
+
<u-details
|
|
16
|
+
#detailsRef
|
|
17
|
+
class="ds-details"
|
|
18
|
+
[attr.data-variant]="variant()"
|
|
19
|
+
[attr.open]="(open() ?? defaultOpen()) || undefined"
|
|
20
|
+
[attr.data-color]="dataColor()"
|
|
21
|
+
[attr.data-size]="dataSize()"
|
|
22
|
+
(toggle)="onToggle($event)"
|
|
23
|
+
>
|
|
24
|
+
<u-summary>
|
|
25
|
+
<ng-content select="ksd-details-summary" />
|
|
26
|
+
</u-summary>
|
|
27
|
+
<div>
|
|
28
|
+
<ng-content select="ksd-details-content" />
|
|
29
|
+
</div>
|
|
30
|
+
</u-details>
|
|
31
|
+
`,
|
|
32
|
+
styles: `
|
|
33
|
+
/* Styles needed since Designsystemet styles doesnt expect an element wrapping .ds-details, which we have */
|
|
34
|
+
.ds-card > :host(:last-of-type) > .ds-details {
|
|
35
|
+
border-bottom: 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.ds-card > :host(:first-of-type) > .ds-details {
|
|
39
|
+
border-top: 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
:host(:not(:first-of-type)) > .ds-details {
|
|
43
|
+
border-top: 0;
|
|
44
|
+
margin-top: 0;
|
|
45
|
+
}
|
|
46
|
+
`,
|
|
47
|
+
})
|
|
48
|
+
export class Details {
|
|
49
|
+
readonly dataSize = input<'sm' | 'md' | 'lg' | undefined>(undefined, {
|
|
50
|
+
// eslint-disable-next-line @angular-eslint/no-input-rename
|
|
51
|
+
alias: 'data-size',
|
|
52
|
+
})
|
|
53
|
+
readonly dataColor = input<string | undefined>(undefined, {
|
|
54
|
+
// eslint-disable-next-line @angular-eslint/no-input-rename
|
|
55
|
+
alias: 'data-color',
|
|
56
|
+
})
|
|
57
|
+
readonly variant = input<'tinted' | 'default'>('default')
|
|
58
|
+
readonly defaultOpen = input<boolean>(false)
|
|
59
|
+
readonly open = input<boolean | undefined>(undefined)
|
|
60
|
+
readonly toggled = output<Event>()
|
|
61
|
+
private detailsRef = viewChild<ElementRef<HTMLDetailsElement>>('detailsRef')
|
|
62
|
+
|
|
63
|
+
onToggle(event: Event) {
|
|
64
|
+
const details = this.detailsRef()?.nativeElement
|
|
65
|
+
if (details && details.open !== this.open()) {
|
|
66
|
+
this.toggled.emit(event)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Component, computed, effect, inject, input } from '@angular/core'
|
|
2
|
+
import { ValidationMessage } from '../validation-message'
|
|
3
|
+
import { FieldState } from './field-state'
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
selector: 'ksd-field-counter',
|
|
7
|
+
imports: [ValidationMessage],
|
|
8
|
+
template: `
|
|
9
|
+
<div data-field="description" class="ds-sr-only" aria-live="polite">
|
|
10
|
+
@if (hasExceededLimit()) {
|
|
11
|
+
{{ excessCount() }} tegn for mye
|
|
12
|
+
}
|
|
13
|
+
</div>
|
|
14
|
+
@if (hasExceededLimit()) {
|
|
15
|
+
<p ksd-validation-message>{{ excessCount() }} tegn for mye</p>
|
|
16
|
+
} @else {
|
|
17
|
+
<p data-field="validation">{{ remainder() }} tegn igjen</p>
|
|
18
|
+
}
|
|
19
|
+
`,
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Apply custom styles here to get correct spacing because
|
|
23
|
+
* the rendered host element from Angular is getting in the way
|
|
24
|
+
*/
|
|
25
|
+
styles: `
|
|
26
|
+
:host > * {
|
|
27
|
+
margin-top: var(--dsc-field-content-spacing);
|
|
28
|
+
}
|
|
29
|
+
`,
|
|
30
|
+
})
|
|
31
|
+
export class FieldCounter {
|
|
32
|
+
/**
|
|
33
|
+
* The maximum allowed characters.
|
|
34
|
+
*
|
|
35
|
+
**/
|
|
36
|
+
readonly limit = input.required<number>()
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* How many characters have been typed.
|
|
40
|
+
*
|
|
41
|
+
**/
|
|
42
|
+
readonly count = input.required<number>()
|
|
43
|
+
protected readonly remainder = computed(() => this.limit() - this.count())
|
|
44
|
+
protected readonly excessCount = computed(() => Math.abs(this.remainder()))
|
|
45
|
+
protected readonly hasExceededLimit = computed(
|
|
46
|
+
() => this.count() > this.limit(),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
private fieldState = inject(FieldState)
|
|
50
|
+
|
|
51
|
+
constructor() {
|
|
52
|
+
effect(() => {
|
|
53
|
+
this.fieldState.hasExceededCounter.set(this.hasExceededLimit())
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Component } from '@angular/core'
|
|
2
|
+
import { ValidationMessage } from '../validation-message'
|
|
3
|
+
|
|
4
|
+
@Component({
|
|
5
|
+
selector: '[ksd-error]',
|
|
6
|
+
template: `<ng-content />`,
|
|
7
|
+
hostDirectives: [
|
|
8
|
+
{
|
|
9
|
+
directive: ValidationMessage,
|
|
10
|
+
},
|
|
11
|
+
],
|
|
12
|
+
})
|
|
13
|
+
export class FieldError {}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifted from Designsystemet core repo.
|
|
3
|
+
* Takes care of binding ids, labels and aria-describedby attributes
|
|
4
|
+
*
|
|
5
|
+
* @param fieldElement - The field element to observe
|
|
6
|
+
* @returns A function to disconnect the observer
|
|
7
|
+
* */
|
|
8
|
+
export function fieldObserver(fieldElement: HTMLElement | null) {
|
|
9
|
+
if (!fieldElement) return
|
|
10
|
+
|
|
11
|
+
const elements = new Map<Element, string | null>()
|
|
12
|
+
const typeCounter = new Map<string, number>() // Track count for each data-field type
|
|
13
|
+
const uuid = `:${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`
|
|
14
|
+
let input: Element | null = null
|
|
15
|
+
let describedby = ''
|
|
16
|
+
|
|
17
|
+
const process = (mutations: Partial<MutationRecord>[]) => {
|
|
18
|
+
const changed: Node[] = []
|
|
19
|
+
const removed: Node[] = []
|
|
20
|
+
|
|
21
|
+
// Merge MutationRecords
|
|
22
|
+
for (const mutation of mutations) {
|
|
23
|
+
if (mutation.attributeName) changed.push(mutation.target ?? fieldElement)
|
|
24
|
+
// @ts-expect-error - addedNodes is not typed
|
|
25
|
+
changed.push(...(mutation.addedNodes || []))
|
|
26
|
+
removed.push(...(mutation.removedNodes || []))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Register elements
|
|
30
|
+
for (const el of changed) {
|
|
31
|
+
if (!isElement(el)) continue
|
|
32
|
+
|
|
33
|
+
if (isLabel(el)) elements.set(el, el.htmlFor)
|
|
34
|
+
else if (el.hasAttribute('data-field')) elements.set(el, el.id)
|
|
35
|
+
else if (isInputLike(el)) {
|
|
36
|
+
input = el
|
|
37
|
+
describedby = el.getAttribute('aria-describedby') || ''
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Reset removed elements
|
|
42
|
+
for (const el of removed) {
|
|
43
|
+
if (!isElement(el)) continue
|
|
44
|
+
|
|
45
|
+
if (input === el) input = null
|
|
46
|
+
if (elements.has(el)) {
|
|
47
|
+
setAttr(el, isLabel(el) ? 'for' : 'id', elements.get(el))
|
|
48
|
+
elements.delete(el)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Connect elements
|
|
53
|
+
const describedbyIds = [describedby] // Keep original aria-describedby
|
|
54
|
+
const inputId = input?.id || uuid
|
|
55
|
+
|
|
56
|
+
// Reset type counters since we reprocess all elements
|
|
57
|
+
typeCounter.clear()
|
|
58
|
+
|
|
59
|
+
for (const [el, value] of elements) {
|
|
60
|
+
const descriptionType = el.getAttribute('data-field')
|
|
61
|
+
let id: string
|
|
62
|
+
|
|
63
|
+
if (descriptionType) {
|
|
64
|
+
// Increment type counter for this type
|
|
65
|
+
const count = (typeCounter.get(descriptionType) || 0) + 1
|
|
66
|
+
typeCounter.set(descriptionType, count)
|
|
67
|
+
id = `${inputId}:${descriptionType}:${count}`
|
|
68
|
+
} else {
|
|
69
|
+
id = inputId
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!value) setAttr(el, isLabel(el) ? 'for' : 'id', id) // Ensure we have a value
|
|
73
|
+
if (descriptionType === 'validation')
|
|
74
|
+
describedbyIds.unshift(el.id) // Validations to the front
|
|
75
|
+
else if (descriptionType) describedbyIds.push(el.id) // Other descriptions to the back
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setAttr(input, 'id', inputId)
|
|
79
|
+
setAttr(input, 'aria-describedby', describedbyIds.join(' ').trim())
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const observer = createOptimizedMutationObserver(process)
|
|
83
|
+
observer.observe(fieldElement, {
|
|
84
|
+
attributeFilter: ['id', 'for', 'aria-describedby'],
|
|
85
|
+
attributes: true,
|
|
86
|
+
childList: true,
|
|
87
|
+
subtree: true,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
process([{ addedNodes: fieldElement.querySelectorAll('*') }]) // Initial setup
|
|
91
|
+
observer.takeRecords() // Clear initial setup queue
|
|
92
|
+
return () => observer.disconnect()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Utilities
|
|
96
|
+
export const isElement = (node: Node) => node instanceof Element
|
|
97
|
+
export const isLabel = (node: Node) => node instanceof HTMLLabelElement
|
|
98
|
+
export const isInputLike = (node: unknown): node is HTMLInputElement =>
|
|
99
|
+
node instanceof HTMLElement &&
|
|
100
|
+
'validity' in node &&
|
|
101
|
+
!(node instanceof HTMLButtonElement) // Matches input, textarea, select and form accosiated custom elements
|
|
102
|
+
|
|
103
|
+
const setAttr = (el: Element | null, name: string, value?: string | null) =>
|
|
104
|
+
value ? el?.setAttribute(name, value) : el?.removeAttribute(name)
|
|
105
|
+
|
|
106
|
+
// Speed up MutationObserver by debouncing, clearing internal queue after changes and only running when page is visible
|
|
107
|
+
function createOptimizedMutationObserver(callback: MutationCallback) {
|
|
108
|
+
const queue: MutationRecord[] = []
|
|
109
|
+
const observer = new MutationObserver((mutations) => {
|
|
110
|
+
if (!queue.length) requestAnimationFrame(process)
|
|
111
|
+
queue.push(...mutations)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const process = () => {
|
|
115
|
+
callback(queue, observer)
|
|
116
|
+
queue.length = 0 // Reset queue
|
|
117
|
+
observer.takeRecords() // Clear queue due to DOM changes in callback
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return observer
|
|
121
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { computed, Injectable, signal } from '@angular/core'
|
|
2
|
+
|
|
3
|
+
@Injectable()
|
|
4
|
+
export class FieldState {
|
|
5
|
+
/**
|
|
6
|
+
* Whether the field counter has exceeded its limit
|
|
7
|
+
*/
|
|
8
|
+
hasExceededCounter = signal(false)
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Whether the field has errors projected from the outside
|
|
12
|
+
*/
|
|
13
|
+
hasProjectedErrors = signal(false)
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether the field has any errors associated with it
|
|
17
|
+
*/
|
|
18
|
+
hasError = computed(
|
|
19
|
+
() => this.hasExceededCounter() || this.hasProjectedErrors(),
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Meta,
|
|
3
|
+
Primary,
|
|
4
|
+
Canvas,
|
|
5
|
+
Controls,
|
|
6
|
+
Story,
|
|
7
|
+
} from '@storybook/addon-docs/blocks'
|
|
8
|
+
|
|
9
|
+
import * as FieldStories from './field.stories'
|
|
10
|
+
|
|
11
|
+
<Meta of={FieldStories} />
|
|
12
|
+
|
|
13
|
+
# Field
|
|
14
|
+
|
|
15
|
+
`Field` er et hjelpemiddel for å automatisk koble sammen labels, input og feilmeldinger. Field kan brukes sammen med input, textarea og select.
|
|
16
|
+
|
|
17
|
+
<Primary />
|
|
18
|
+
<Controls />
|
|
19
|
+
|
|
20
|
+
## Bruk
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import {
|
|
24
|
+
Field,
|
|
25
|
+
FieldError,
|
|
26
|
+
Label,
|
|
27
|
+
Input,
|
|
28
|
+
} from '@ks-digital/designsystem-angular'
|
|
29
|
+
;<ksd-field>
|
|
30
|
+
<ksd-label>Kort beskrivelse</ksd-label>
|
|
31
|
+
<input ksd-input type="text" />
|
|
32
|
+
<ksd-error>Feilmelding</ksd-error>
|
|
33
|
+
</ksd-field>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Feilmeldinger
|
|
37
|
+
|
|
38
|
+
Bruk `<ksd-error>` for å sette en feilmelding. Du må selv kontrollere når disse skal vises eller skjules. Det er tillatt å vise flere fielmeldinger av gangen.
|
|
39
|
+
|
|
40
|
+
<Canvas of={FieldStories.Error} />
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/angular'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { Input } from '../input/input'
|
|
4
|
+
import { Label } from '../label/label'
|
|
5
|
+
import { Field } from './field'
|
|
6
|
+
import { FieldDescription } from './field-description'
|
|
7
|
+
|
|
8
|
+
test('should connect checkbox and label', async () => {
|
|
9
|
+
await render(
|
|
10
|
+
`
|
|
11
|
+
<ksd-field>
|
|
12
|
+
<ksd-label> Check me </ksd-label>
|
|
13
|
+
<input ksd-input type="checkbox" value="telefon" />
|
|
14
|
+
</ksd-field>`,
|
|
15
|
+
{ imports: [Field, Label, Input] },
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
const label = screen.getByText('Check me')
|
|
19
|
+
const checkbox = screen.getByRole('checkbox')
|
|
20
|
+
|
|
21
|
+
expect(label.getAttribute('for')).toBe(checkbox.getAttribute('id'))
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('should connect checkbox and description', () => {
|
|
25
|
+
test('should connect checkbox and description', async () => {
|
|
26
|
+
await render(
|
|
27
|
+
`
|
|
28
|
+
<ksd-field>
|
|
29
|
+
<ksd-label> Check me </ksd-label>
|
|
30
|
+
<input ksd-input type="checkbox" value="telefon" />
|
|
31
|
+
<p ksd-field-description>Description</p>
|
|
32
|
+
</ksd-field>`,
|
|
33
|
+
{ imports: [Field, Label, Input, FieldDescription] },
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const checkbox = screen.getByRole('checkbox')
|
|
37
|
+
const description = screen.getByText('Description')
|
|
38
|
+
|
|
39
|
+
expect(checkbox.getAttribute('aria-describedby')).toBe(
|
|
40
|
+
description.getAttribute('id'),
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('should not connect checkbox and description if description is not provided', async () => {
|
|
45
|
+
await render(
|
|
46
|
+
`
|
|
47
|
+
<ksd-field>
|
|
48
|
+
<ksd-label> Check me </ksd-label>
|
|
49
|
+
<input ksd-input type="checkbox" value="telefon" />
|
|
50
|
+
</ksd-field>`,
|
|
51
|
+
{ imports: [Field, Label, Input] },
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const checkbox = screen.getByRole('checkbox')
|
|
55
|
+
expect(checkbox.getAttribute('aria-describedby')).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('should keep existing aria-describedby', async () => {
|
|
59
|
+
await render(
|
|
60
|
+
`
|
|
61
|
+
<ksd-field>
|
|
62
|
+
<ksd-label> Check me </ksd-label>
|
|
63
|
+
<input ksd-input type="checkbox" value="telefon" aria-describedby="existing-id" />
|
|
64
|
+
</ksd-field>`,
|
|
65
|
+
{ imports: [Field, Label, Input] },
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
const checkbox = screen.getByRole('checkbox')
|
|
69
|
+
expect(checkbox.getAttribute('aria-describedby')).toBe('existing-id')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('should pass through a user-supplied id', async () => {
|
|
73
|
+
await render(
|
|
74
|
+
`
|
|
75
|
+
<ksd-field>
|
|
76
|
+
<ksd-label> Check me </ksd-label>
|
|
77
|
+
<input ksd-input type="checkbox" value="telefon" id="test" />
|
|
78
|
+
</ksd-field>`,
|
|
79
|
+
{ imports: [Field, Label, Input] },
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
const input = screen.getByRole('checkbox')
|
|
83
|
+
|
|
84
|
+
expect(input).toHaveAttribute('id', 'test')
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('FieldCounter', () => {
|
|
89
|
+
test('should render counter with description and validation message connected to input', async () => {
|
|
90
|
+
await render(
|
|
91
|
+
`
|
|
92
|
+
<ksd-field>
|
|
93
|
+
<ksd-label> Check me </ksd-label>
|
|
94
|
+
<input ksd-input [counter]="5" type="text" />
|
|
95
|
+
</ksd-field>`,
|
|
96
|
+
{ imports: [Field, Label, Input] },
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
const counter = screen.getByText('5 tegn igjen')
|
|
100
|
+
const input = screen.getByRole('textbox')
|
|
101
|
+
|
|
102
|
+
expect(input.getAttribute('aria-describedby')).toContain(
|
|
103
|
+
counter.getAttribute('id'),
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('should show a validation message if the input is too long', async () => {
|
|
108
|
+
await render(
|
|
109
|
+
`
|
|
110
|
+
<ksd-field>
|
|
111
|
+
<ksd-label> Check me </ksd-label>
|
|
112
|
+
<input ksd-input [counter]="5" type="text" />
|
|
113
|
+
</ksd-field>`,
|
|
114
|
+
{ imports: [Field, Label, Input] },
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
const input = screen.getByRole('textbox')
|
|
118
|
+
const user = userEvent.setup()
|
|
119
|
+
|
|
120
|
+
await user.type(input, '123456')
|
|
121
|
+
|
|
122
|
+
const visibleMessage = screen.getByText('1 tegn for mye', { selector: 'p' })
|
|
123
|
+
expect(visibleMessage).toBeInTheDocument()
|
|
124
|
+
|
|
125
|
+
const screenReaderMessage = screen.getByText('1 tegn for mye', {
|
|
126
|
+
selector: 'div',
|
|
127
|
+
})
|
|
128
|
+
expect(screenReaderMessage).toBeInTheDocument()
|
|
129
|
+
expect(screenReaderMessage).toHaveAttribute('aria-live', 'polite')
|
|
130
|
+
})
|
|
131
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import {
|
|
2
|
+
argsToTemplate,
|
|
3
|
+
moduleMetadata,
|
|
4
|
+
type Meta,
|
|
5
|
+
type StoryObj,
|
|
6
|
+
} from '@storybook/angular'
|
|
7
|
+
import { CommonArgs } from '../../../.storybook/default-args'
|
|
8
|
+
import { Input } from '../input/input'
|
|
9
|
+
import { Label } from '../label/label'
|
|
10
|
+
import { Field } from './field'
|
|
11
|
+
import { FieldDescription } from './field-description'
|
|
12
|
+
import { FieldError } from './field-error'
|
|
13
|
+
|
|
14
|
+
type FieldArgs = CommonArgs & {
|
|
15
|
+
readonly: boolean
|
|
16
|
+
disabled: boolean
|
|
17
|
+
counter: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const meta: Meta<Field> = {
|
|
21
|
+
component: Field,
|
|
22
|
+
title: 'Komponenter/Field',
|
|
23
|
+
decorators: [
|
|
24
|
+
moduleMetadata({
|
|
25
|
+
imports: [Label, Field, Input, FieldError, FieldDescription],
|
|
26
|
+
}),
|
|
27
|
+
],
|
|
28
|
+
}
|
|
29
|
+
export default meta
|
|
30
|
+
type Story = StoryObj<FieldArgs>
|
|
31
|
+
|
|
32
|
+
export const Preview: Story = {
|
|
33
|
+
args: {
|
|
34
|
+
readonly: false,
|
|
35
|
+
disabled: false,
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
render: (args) => ({
|
|
39
|
+
props: args,
|
|
40
|
+
template: `
|
|
41
|
+
<ksd-field>
|
|
42
|
+
<ksd-label>Etternavn</ksd-label>
|
|
43
|
+
<div ksd-field-description>Etternavn kan ikke inneholde mellomrom</div>
|
|
44
|
+
<input ksd-input type="text" ${argsToTemplate(args)} />
|
|
45
|
+
</ksd-field>
|
|
46
|
+
`,
|
|
47
|
+
}),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const Rows: Story = {
|
|
51
|
+
args: {
|
|
52
|
+
...Preview.args,
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
render: (args) => ({
|
|
56
|
+
props: args,
|
|
57
|
+
template: `
|
|
58
|
+
<ksd-field>
|
|
59
|
+
<ksd-label>Label</ksd-label>
|
|
60
|
+
<textarea ksd-input type="text" rows="4" ${argsToTemplate(args)}></textarea>
|
|
61
|
+
</ksd-field>
|
|
62
|
+
`,
|
|
63
|
+
}),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const Counter: Story = {
|
|
67
|
+
args: {
|
|
68
|
+
...Preview.args,
|
|
69
|
+
counter: 5,
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
render: (args) => ({
|
|
73
|
+
props: args,
|
|
74
|
+
template: `
|
|
75
|
+
<ksd-field>
|
|
76
|
+
<ksd-label>Label</ksd-label>
|
|
77
|
+
<input ksd-input [counter]="5" type="text" ${argsToTemplate(args)} />
|
|
78
|
+
</ksd-field>
|
|
79
|
+
`,
|
|
80
|
+
}),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const Error: Story = {
|
|
84
|
+
args: {
|
|
85
|
+
...Preview.args,
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
render: (args) => ({
|
|
89
|
+
props: args,
|
|
90
|
+
template: `
|
|
91
|
+
<ksd-field>
|
|
92
|
+
<ksd-label>Navn</ksd-label>
|
|
93
|
+
<input ksd-input type="text" ${argsToTemplate(args)} />
|
|
94
|
+
<p ksd-error>Feltet må fylles ut</p>
|
|
95
|
+
</ksd-field>
|
|
96
|
+
`,
|
|
97
|
+
}),
|
|
98
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterNextRender,
|
|
3
|
+
Component,
|
|
4
|
+
computed,
|
|
5
|
+
contentChild,
|
|
6
|
+
contentChildren,
|
|
7
|
+
effect,
|
|
8
|
+
ElementRef,
|
|
9
|
+
inject,
|
|
10
|
+
input,
|
|
11
|
+
} from '@angular/core'
|
|
12
|
+
import { CommonInputs } from '../common-inputs'
|
|
13
|
+
import { Input } from '../input/input'
|
|
14
|
+
import { Label } from '../label/label'
|
|
15
|
+
import { ValidationMessage } from '../validation-message'
|
|
16
|
+
import { FieldCounter } from './field-counter'
|
|
17
|
+
import { fieldObserver } from './field-observer'
|
|
18
|
+
import { FieldState } from './field-state'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Use the Field component to connect inputs and labels
|
|
22
|
+
*/
|
|
23
|
+
@Component({
|
|
24
|
+
selector: 'ksd-field',
|
|
25
|
+
hostDirectives: [
|
|
26
|
+
{
|
|
27
|
+
directive: CommonInputs,
|
|
28
|
+
inputs: ['data-size', 'data-color'],
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
host: {
|
|
32
|
+
class: 'ds-field',
|
|
33
|
+
'[attr.dataPosition]': 'position()',
|
|
34
|
+
},
|
|
35
|
+
template: `
|
|
36
|
+
<ng-content />
|
|
37
|
+
@if (hasCounter()) {
|
|
38
|
+
<ksd-field-counter [limit]="limit() ?? 0" [count]="count() ?? 0" />
|
|
39
|
+
}
|
|
40
|
+
`,
|
|
41
|
+
imports: [FieldCounter],
|
|
42
|
+
providers: [FieldState],
|
|
43
|
+
})
|
|
44
|
+
export class Field {
|
|
45
|
+
/**
|
|
46
|
+
* Position of toggle inputs (radio, checkbox, switch) in field
|
|
47
|
+
* @default start
|
|
48
|
+
*/
|
|
49
|
+
position = input<'start' | 'end'>('start')
|
|
50
|
+
|
|
51
|
+
private readonly fieldState = inject(FieldState)
|
|
52
|
+
private readonly input = contentChild(Input)
|
|
53
|
+
private readonly label = contentChild(Label)
|
|
54
|
+
private readonly projectedErrors = contentChildren(ValidationMessage)
|
|
55
|
+
|
|
56
|
+
private readonly el = inject(ElementRef)
|
|
57
|
+
protected readonly count = computed(() => this.input()?.value().length)
|
|
58
|
+
protected readonly limit = computed(() => this.input()?.counter())
|
|
59
|
+
protected readonly hasCounter = computed(() => this.limit())
|
|
60
|
+
|
|
61
|
+
constructor() {
|
|
62
|
+
afterNextRender(() => {
|
|
63
|
+
fieldObserver(this.el.nativeElement)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
effect(() => {
|
|
67
|
+
this.fieldState.hasProjectedErrors.set(this.projectedErrors().length > 0)
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
}
|