diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/fragments/text-counter.mdx b/packages/dnb-design-system-portal/src/docs/uilib/components/fragments/text-counter.mdx
new file mode 100644
index 00000000000..2d3c8bf3692
--- /dev/null
+++ b/packages/dnb-design-system-portal/src/docs/uilib/components/fragments/text-counter.mdx
@@ -0,0 +1,15 @@
+---
+title: 'TextCounter'
+description: 'The TextCounter is a component designed to provide real-time character count feedback in text input fields.'
+showTabs: true
+theme: 'sbanken'
+status: null
+hideTabs:
+ - title: Events
+---
+
+import Info from './text-counter/info'
+import Demos from './text-counter/demos'
+
+
+
diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/fragments/text-counter/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/components/fragments/text-counter/Examples.tsx
new file mode 100644
index 00000000000..646d77d0f15
--- /dev/null
+++ b/packages/dnb-design-system-portal/src/docs/uilib/components/fragments/text-counter/Examples.tsx
@@ -0,0 +1,77 @@
+/**
+ * UI lib Component Example
+ *
+ */
+
+import React from 'react'
+import ComponentBox from '../../../../../shared/tags/ComponentBox'
+import { TextCounter } from '@dnb/eufemia/src/fragments'
+import { Field, Form } from '@dnb/eufemia/src/extensions/forms'
+import { Flex } from '@dnb/eufemia/src'
+import type { TextCounterProps } from '@dnb/eufemia/src/fragments/text-counter/TextCounter'
+
+export function CountCharactersDown() {
+ return (
+
+
+
+ )
+}
+
+export function CountCharactersUp() {
+ return (
+
+
+
+ )
+}
+
+export function CountCharactersInteractive() {
+ return (
+
+ {() => {
+ const Counter = () => {
+ const { data } = Form.useData('text-counter-up', initialData)
+ return (
+
+
+
+
+
+
+
+ )
+ }
+
+ const variant: TextCounterProps['variant'] = 'down'
+ const initialData = {
+ max: 10,
+ variant,
+ text: 'Count me!',
+ }
+
+ return (
+
+
+
+ )
+ }}
+
+ )
+}
diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/fragments/text-counter/demos.mdx b/packages/dnb-design-system-portal/src/docs/uilib/components/fragments/text-counter/demos.mdx
new file mode 100644
index 00000000000..4a7a14bbad9
--- /dev/null
+++ b/packages/dnb-design-system-portal/src/docs/uilib/components/fragments/text-counter/demos.mdx
@@ -0,0 +1,19 @@
+---
+showTabs: true
+---
+
+import * as Examples from './Examples'
+
+## Demos
+
+### Count characters downwards
+
+
+
+### Count characters upwards
+
+
+
+### Interactive
+
+
diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/fragments/text-counter/info.mdx b/packages/dnb-design-system-portal/src/docs/uilib/components/fragments/text-counter/info.mdx
new file mode 100644
index 00000000000..801a11e4d64
--- /dev/null
+++ b/packages/dnb-design-system-portal/src/docs/uilib/components/fragments/text-counter/info.mdx
@@ -0,0 +1,11 @@
+---
+showTabs: true
+---
+
+## Description
+
+The `TextCounter` is a component designed to provide real-time character count feedback in text input fields.
+
+It provides the correct text translations and color and a visual indicator of the remaining characters.
+
+It is used in the [Textarea](/uilib/components/textarea/) component.
diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/fragments/text-counter/properties.mdx b/packages/dnb-design-system-portal/src/docs/uilib/components/fragments/text-counter/properties.mdx
new file mode 100644
index 00000000000..e4a6acc4241
--- /dev/null
+++ b/packages/dnb-design-system-portal/src/docs/uilib/components/fragments/text-counter/properties.mdx
@@ -0,0 +1,13 @@
+---
+showTabs: true
+---
+
+## Properties
+
+| Properties | Description |
+| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
+| `text` | _(required)_ The text to count characters from. |
+| `max` | _(required)_ The maximum number of characters allowed. |
+| `variant` | _(optional)_ The counting variant. Can be either `up` (counts up from zero) or `down` (counts down from max). Default is `down`. |
+| `bypassAriaLive` | _(optional)_ If true, AriaLive is bypassed. This is useful to signal that a given input field value should not be read out. |
+| [Space](/uilib/layout/space/properties) | _(optional)_ spacing properties like `top` or `bottom` are supported. |
diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/textarea/properties.mdx b/packages/dnb-design-system-portal/src/docs/uilib/components/textarea/properties.mdx
index 42e982a95b0..f4789faa603 100644
--- a/packages/dnb-design-system-portal/src/docs/uilib/components/textarea/properties.mdx
+++ b/packages/dnb-design-system-portal/src/docs/uilib/components/textarea/properties.mdx
@@ -16,7 +16,7 @@ showTabs: true
| `label_sr_only` | _(optional)_ use `true` to make the label only readable by screen readers. |
| `autoresize` | _(optional)_ use `true` to make the Textarea grow and shrink depending on how many lines the user has filled. |
| `autoresize_max_rows` | _(optional)_ set a number to define how many rows the Textarea can auto grow. |
-| `characterCounter` | _(optional)_ use `true` to show a character counter. You need to set a `maxLength={number}` in order to show the counter. |
+| `characterCounter` | _(optional)_ use `up` or `down` or `true` (down) to show a character counter. You need to set a `maxLength={number}` in order to show the counter. |
| `status` | _(optional)_ text with a status message. The style defaults to an error message. You can use `true` to only get the status color, without a message. |
| `status_state` | _(optional)_ defines the state of the status. Currently, there are two statuses `[error, info]`. Defaults to `error`. |
| `status_props` | _(optional)_ use an object to define additional FormStatus properties. |
diff --git a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/String/properties.mdx b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/String/properties.mdx
index 662f70ea97a..0668d223dbb 100644
--- a/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/String/properties.mdx
+++ b/packages/dnb-design-system-portal/src/docs/uilib/extensions/forms/base-fields/String/properties.mdx
@@ -6,28 +6,28 @@ import DataValueReadwriteProperties from '../../data-value-readwrite-properties.
### Component-specific props
-| Property | Type | Description |
-| --------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `type` | `string` | _(optional)_ Input DOM element type. |
-| `multiline` | `boolean` | _(optional)_ True to be able to write in multiple lines (switching from input-element to textarea-element). |
-| `leftIcon` | `string` | _(optional)_ For icon at the left side of the text input. |
-| `rightIcon` | `string` | _(optional)_ For icon at the right side of the text input. |
-| `inputClassName` | `string` | _(optional)_ Class name set on the <input> DOM element. |
-| `innerRef` | `React.ref` | _(optional)_ by providing a React.ref we can get the internally used input element (DOM). E.g. `innerRef={myRef}` by using `React.createRef()` or `React.useRef()`. |
-| `clear` | `boolean` | _(optional)_ True to have a clickable clear-icon for removing the active value. |
-| `autoresize` | `boolean` | _(optional)_ For `multiline`, set `true` to expand when writing longer texts. |
-| `autoComplete` | `on` or `string` | _(optional)_ For HTML `autocomplete` [attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete). |
-| `capitalize` | `boolean` | _(optional)_ When set to `true`, it will capitalize the first letter of every word, transforming the rest to lowercase. |
-| `trim` | `boolean` | _(optional)_ When `true`, it will trim leading and trailing whitespaces on blur, triggering onChange if the value changes. |
-| `inputMode` | `string` | _(optional)_ Define a [inputmode](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/inputmode). |
-| `autoresizeMaxRows` | `boolean` | _(optional)_ For `multiline`, set how many rows of text can be shown at max. |
-| `characterCounter` | `boolean` | _(optional)_ True to show a character counter. You need to set a `maxLength={number}` as well as have `multiline` enabled in order to show the counter. |
-| `minLength` | `number` | _(optional)_ Validation for minimum length of the text (number of characters). |
-| `maxLength` | `number` | _(optional)_ Validation for maximum length of the text (number of characters). |
-| `pattern` | `string` | _(optional)_ Validation based on regex pattern. |
-| `width` | `string` or `false` | _(optional)_ `false` for no width (use browser default), `small`, `medium` or `large` for predefined standard widths, `stretch` for fill available width. |
-| `help` | `object` | _(optional)_ Provide a help button. Object consisting of `title` and `contents`. |
-| [Space](/uilib/layout/space/properties) | Various | _(optional)_ Spacing properties like `top` or `bottom` are supported. |
+| Property | Type | Description |
+| --------------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `type` | `string` | _(optional)_ Input DOM element type. |
+| `multiline` | `boolean` | _(optional)_ True to be able to write in multiple lines (switching from input-element to textarea-element). |
+| `leftIcon` | `string` | _(optional)_ For icon at the left side of the text input. |
+| `rightIcon` | `string` | _(optional)_ For icon at the right side of the text input. |
+| `inputClassName` | `string` | _(optional)_ Class name set on the <input> DOM element. |
+| `innerRef` | `React.ref` | _(optional)_ by providing a React.ref we can get the internally used input element (DOM). E.g. `innerRef={myRef}` by using `React.createRef()` or `React.useRef()`. |
+| `clear` | `boolean` | _(optional)_ True to have a clickable clear-icon for removing the active value. |
+| `autoresize` | `boolean` | _(optional)_ For `multiline`, set `true` to expand when writing longer texts. |
+| `autoComplete` | `on` or `string` | _(optional)_ For HTML `autocomplete` [attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete). |
+| `capitalize` | `boolean` | _(optional)_ When set to `true`, it will capitalize the first letter of every word, transforming the rest to lowercase. |
+| `trim` | `boolean` | _(optional)_ When `true`, it will trim leading and trailing whitespaces on blur, triggering onChange if the value changes. |
+| `inputMode` | `string` | _(optional)_ Define a [inputmode](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/inputmode). |
+| `autoresizeMaxRows` | `boolean` | _(optional)_ For `multiline`, set how many rows of text can be shown at max. |
+| `characterCounter` | `boolean` or `string` | _(optional)_ Use `up` or `down` or `true` (down) to show a character counter. You need to set a `maxLength={number}` as well as have `multiline` enabled in order to show the counter. |
+| `minLength` | `number` | _(optional)_ Validation for minimum length of the text (number of characters). |
+| `maxLength` | `number` | _(optional)_ Validation for maximum length of the text (number of characters). |
+| `pattern` | `string` | _(optional)_ Validation based on regex pattern. |
+| `width` | `string` or `false` | _(optional)_ `false` for no width (use browser default), `small`, `medium` or `large` for predefined standard widths, `stretch` for fill available width. |
+| `help` | `object` | _(optional)_ Provide a help button. Object consisting of `title` and `contents`. |
+| [Space](/uilib/layout/space/properties) | Various | _(optional)_ Spacing properties like `top` or `bottom` are supported. |
## Properties
diff --git a/packages/dnb-eufemia/src/components/textarea/Textarea.d.ts b/packages/dnb-eufemia/src/components/textarea/Textarea.d.ts
index f3c57763dd4..eea0da863d6 100644
--- a/packages/dnb-eufemia/src/components/textarea/Textarea.d.ts
+++ b/packages/dnb-eufemia/src/components/textarea/Textarea.d.ts
@@ -88,7 +88,7 @@ export interface TextareaProps
*/
autoresize?: boolean;
/**
- * use `true` to show a character counter.
+ * Use `up` or `down` or `true` (down) to show a character counter. You need to set a `maxLength={number}` in order to show the counter.
*/
characterCounter?: boolean;
/**
diff --git a/packages/dnb-eufemia/src/components/textarea/Textarea.js b/packages/dnb-eufemia/src/components/textarea/Textarea.js
index a92aff028ca..bb6971138cd 100644
--- a/packages/dnb-eufemia/src/components/textarea/Textarea.js
+++ b/packages/dnb-eufemia/src/components/textarea/Textarea.js
@@ -8,7 +8,7 @@ import PropTypes from 'prop-types'
import classnames from 'classnames'
import FormLabel from '../form-label/FormLabel'
import FormStatus from '../form-status/FormStatus'
-import AriaLive from '../aria-live/AriaLive'
+import TextCounter from '../../fragments/text-counter/TextCounter'
import {
isTrue,
makeUniqueId,
@@ -77,7 +77,10 @@ export default class Textarea extends React.PureComponent {
stretch: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
disabled: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
skeleton: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
- characterCounter: PropTypes.bool,
+ characterCounter: PropTypes.oneOfType([
+ PropTypes.oneOf(['down', 'up']),
+ PropTypes.bool,
+ ]),
autoresize: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
autoresize_max_rows: PropTypes.oneOfType([
PropTypes.string,
@@ -336,33 +339,6 @@ export default class Textarea extends React.PureComponent {
getLineHeight() {
return parseFloat(getComputedStyle(this._ref.current).lineHeight) || 0
}
- getCounter = () => {
- const { characterCounter, maxLength } = this.props
-
- if (characterCounter !== true || !maxLength) {
- return null
- }
-
- const { value, textareaState } = this.state
- const count = (value || '').length
-
- const message = this.context
- .getTranslation(this.props)
- .Textarea.characterCounter.replace('%count', count)
- .replace('%max', maxLength)
-
- return (
- <>
- {message}
- 0 && textareaState === 'virgin'}
- delay={2000}
- >
- {message}
-
- >
- )
- }
render() {
// use only the props from context, who are available here anyway
const props = extendPropsWithContextInClassComponent(
@@ -397,8 +373,9 @@ export default class Textarea extends React.PureComponent {
class: _className,
className,
autoresize,
+ characterCounter,
+ maxLength,
autoresize_max_rows, //eslint-disable-line
- characterCounter, //eslint-disable-line
id: _id, //eslint-disable-line
children, //eslint-disable-line
value: _value, //eslint-disable-line
@@ -427,8 +404,9 @@ export default class Textarea extends React.PureComponent {
role: 'textbox',
value: hasValue ? value : '',
id,
- disabled: isTrue(disabled) || isTrue(skeleton),
name: id,
+ maxLength,
+ disabled: isTrue(disabled) || isTrue(skeleton),
'aria-placeholder': placeholder,
...attributes,
...textareaAttributes,
@@ -569,7 +547,16 @@ export default class Textarea extends React.PureComponent {
)}
- {this.getCounter()}
+ {characterCounter && maxLength > 0 && (
+
+ )}
)
diff --git a/packages/dnb-eufemia/src/components/textarea/__tests__/Textarea.test.tsx b/packages/dnb-eufemia/src/components/textarea/__tests__/Textarea.test.tsx
index a1fbdd0a525..46880335265 100644
--- a/packages/dnb-eufemia/src/components/textarea/__tests__/Textarea.test.tsx
+++ b/packages/dnb-eufemia/src/components/textarea/__tests__/Textarea.test.tsx
@@ -302,27 +302,27 @@ describe('Textarea component', () => {
)
- const counter = document.querySelector('.dnb-textarea__counter')
+ const counter = document.querySelector('.dnb-text-counter__message')
const textarea = document.querySelector('textarea')
const ariaLive = document.querySelector('.dnb-aria-live')
- expect(counter).toHaveTextContent('3 av 8 gjenstående tegn')
+ expect(counter).toHaveTextContent('5 av 8 tegn gjenstår')
expect(ariaLive).toHaveTextContent('')
await userEvent.type(textarea, 'bar')
- expect(counter).toHaveTextContent('6 av 8 gjenstående tegn')
- expect(ariaLive).toHaveTextContent('6 av 8 gjenstående tegn')
+ expect(counter).toHaveTextContent('2 av 8 tegn gjenstår')
+ expect(ariaLive).toHaveTextContent('2 av 8 tegn gjenstår')
rerender(
)
- expect(counter).toHaveTextContent('6 of 8 characters remaining')
+ expect(counter).toHaveTextContent('2 of 8 characters remaining')
await userEvent.type(textarea, 'baz')
- expect(ariaLive).toHaveTextContent('8 of 8 characters remaining')
+ expect(ariaLive).toHaveTextContent('0 of 8 characters remaining')
})
})
diff --git a/packages/dnb-eufemia/src/components/textarea/__tests__/__image_snapshots__/textarea-for-sbanken-have-to-match-character-counter.snap.png b/packages/dnb-eufemia/src/components/textarea/__tests__/__image_snapshots__/textarea-for-sbanken-have-to-match-character-counter.snap.png
index 151e8faf25c..b52ed3bef8b 100644
Binary files a/packages/dnb-eufemia/src/components/textarea/__tests__/__image_snapshots__/textarea-for-sbanken-have-to-match-character-counter.snap.png and b/packages/dnb-eufemia/src/components/textarea/__tests__/__image_snapshots__/textarea-for-sbanken-have-to-match-character-counter.snap.png differ
diff --git a/packages/dnb-eufemia/src/components/textarea/__tests__/__image_snapshots__/textarea-for-ui-have-to-match-character-counter.snap.png b/packages/dnb-eufemia/src/components/textarea/__tests__/__image_snapshots__/textarea-for-ui-have-to-match-character-counter.snap.png
index e03080488f8..9b46321f737 100644
Binary files a/packages/dnb-eufemia/src/components/textarea/__tests__/__image_snapshots__/textarea-for-ui-have-to-match-character-counter.snap.png and b/packages/dnb-eufemia/src/components/textarea/__tests__/__image_snapshots__/textarea-for-ui-have-to-match-character-counter.snap.png differ
diff --git a/packages/dnb-eufemia/src/components/textarea/__tests__/__snapshots__/Textarea.test.tsx.snap b/packages/dnb-eufemia/src/components/textarea/__tests__/__snapshots__/Textarea.test.tsx.snap
index 613114ef0d1..978cd113b39 100644
--- a/packages/dnb-eufemia/src/components/textarea/__tests__/__snapshots__/Textarea.test.tsx.snap
+++ b/packages/dnb-eufemia/src/components/textarea/__tests__/__snapshots__/Textarea.test.tsx.snap
@@ -183,11 +183,9 @@ button .dnb-form-status__text {
.dnb-textarea__suffix.dnb-suffix {
padding-left: 1rem;
}
-.dnb-textarea__counter {
+.dnb-textarea .dnb-text-counter {
margin-top: 0.5rem;
margin-left: -0.5rem;
- font-size: var(--font-size-small);
- color: var(--color-black-55);
}
.dnb-textarea__textarea {
position: relative;
diff --git a/packages/dnb-eufemia/src/components/textarea/style/dnb-textarea.scss b/packages/dnb-eufemia/src/components/textarea/style/dnb-textarea.scss
index a3b80b04e8e..f610afa5cfc 100644
--- a/packages/dnb-eufemia/src/components/textarea/style/dnb-textarea.scss
+++ b/packages/dnb-eufemia/src/components/textarea/style/dnb-textarea.scss
@@ -70,12 +70,9 @@
padding-left: 1rem;
}
- &__counter {
+ .dnb-text-counter {
margin-top: 0.5rem;
margin-left: -0.5rem;
-
- font-size: var(--font-size-small);
- color: var(--color-black-55);
}
&__textarea {
diff --git a/packages/dnb-eufemia/src/extensions/forms/Field/String/__tests__/String.test.tsx b/packages/dnb-eufemia/src/extensions/forms/Field/String/__tests__/String.test.tsx
index c5bc37e200f..ef76ae98228 100644
--- a/packages/dnb-eufemia/src/extensions/forms/Field/String/__tests__/String.test.tsx
+++ b/packages/dnb-eufemia/src/extensions/forms/Field/String/__tests__/String.test.tsx
@@ -817,17 +817,17 @@ describe('Field.String', () => {
)
- const counter = document.querySelector('.dnb-textarea__counter')
+ const counter = document.querySelector('.dnb-text-counter')
const textarea = document.querySelector('textarea')
const ariaLive = document.querySelector('.dnb-aria-live')
- expect(counter).toHaveTextContent('3 av 8 gjenstående tegn')
+ expect(counter).toHaveTextContent('5 av 8 tegn gjenstår')
expect(ariaLive).toHaveTextContent('')
await userEvent.type(textarea, 'bar')
- expect(counter).toHaveTextContent('6 av 8 gjenstående tegn')
- expect(ariaLive).toHaveTextContent('6 av 8 gjenstående tegn')
+ expect(counter).toHaveTextContent('2 av 8 tegn gjenstår')
+ expect(ariaLive).toHaveTextContent('2 av 8 tegn gjenstår')
rerender(
@@ -840,11 +840,11 @@ describe('Field.String', () => {
)
- expect(counter).toHaveTextContent('6 of 8 characters remaining')
+ expect(counter).toHaveTextContent('2 of 8 characters remaining')
await userEvent.type(textarea, 'baz')
- expect(ariaLive).toHaveTextContent('8 of 8 characters remaining')
+ expect(ariaLive).toHaveTextContent('0 of 8 characters remaining')
})
it('gets valid ref element', () => {
diff --git a/packages/dnb-eufemia/src/fragments/TextCounter.ts b/packages/dnb-eufemia/src/fragments/TextCounter.ts
new file mode 100644
index 00000000000..65ee6d64d39
--- /dev/null
+++ b/packages/dnb-eufemia/src/fragments/TextCounter.ts
@@ -0,0 +1,14 @@
+/**
+ * ATTENTION: This file is auto generated by using "prepareTemplates".
+ * Do not change the content!
+ *
+ */
+
+/**
+ * Library Index text-counter to autogenerate all the fragments and extensions
+ * Used by "prepareTextCounters"
+ */
+
+import TextCounter from './text-counter/TextCounter'
+export * from './text-counter/TextCounter'
+export default TextCounter
diff --git a/packages/dnb-eufemia/src/fragments/index.ts b/packages/dnb-eufemia/src/fragments/index.ts
index 778634be35d..fe23ce7148a 100644
--- a/packages/dnb-eufemia/src/fragments/index.ts
+++ b/packages/dnb-eufemia/src/fragments/index.ts
@@ -12,6 +12,7 @@
// import all the available components
import DrawerList from './drawer-list/DrawerList'
import ScrollView from './scroll-view/ScrollView'
+import TextCounter from './text-counter/TextCounter'
// define / export all the available components
-export { DrawerList, ScrollView }
+export { DrawerList, ScrollView, TextCounter }
diff --git a/packages/dnb-eufemia/src/fragments/lib.ts b/packages/dnb-eufemia/src/fragments/lib.ts
index f711f4ec234..f97a2f1d768 100644
--- a/packages/dnb-eufemia/src/fragments/lib.ts
+++ b/packages/dnb-eufemia/src/fragments/lib.ts
@@ -12,10 +12,11 @@
// import all the available fragments
import DrawerList from './drawer-list/DrawerList'
import ScrollView from './scroll-view/ScrollView'
+import TextCounter from './text-counter/TextCounter'
// define / export all the available fragments
-export { DrawerList, ScrollView }
+export { DrawerList, ScrollView, TextCounter }
export const getFragments = () => {
- return { DrawerList, ScrollView }
+ return { DrawerList, ScrollView, TextCounter }
}
diff --git a/packages/dnb-eufemia/src/fragments/text-counter/TextCounter.tsx b/packages/dnb-eufemia/src/fragments/text-counter/TextCounter.tsx
new file mode 100644
index 00000000000..148c38ff26c
--- /dev/null
+++ b/packages/dnb-eufemia/src/fragments/text-counter/TextCounter.tsx
@@ -0,0 +1,65 @@
+import React, { useContext, useMemo } from 'react'
+import Context from '../../shared/Context'
+import { toPascalCase } from '../../shared/component-helper'
+import { AriaLive, Space } from '../../components'
+import type { LocaleProps } from '../../shared/types'
+import type { SpaceProps } from '../../components/Space'
+import classnames from 'classnames'
+
+export type TextCounterProps = {
+ variant?: 'down' | 'up' | true
+ text: string
+ max: number
+ bypassAriaLive?: boolean
+} & React.HTMLAttributes &
+ LocaleProps &
+ SpaceProps
+
+export default function TextCounter(localProps: TextCounterProps) {
+ const context = useContext(Context)
+ const {
+ variant,
+ text,
+ max,
+ bypassAriaLive = false,
+ className,
+ locale, // eslint-disable-line
+ ...rest
+ } = localProps
+
+ const length = (text || '').length
+
+ const message = useMemo(() => {
+ if (!(max > 0)) {
+ return ''
+ }
+
+ let count = variant === 'down' ? max - length : length
+ if (count > max) {
+ count = max
+ } else if (count < 0) {
+ count = 0
+ }
+
+ return context
+ .getTranslation(localProps)
+ .TextCounter[`character${toPascalCase(variant || 'down')}`].replace(
+ '%count',
+ String(count)
+ )
+ .replace('%max', String(max))
+ }, [variant, max, length, context, localProps])
+
+ return (
+
+ {message}
+ 0 && bypassAriaLive} delay={2000}>
+ {message}
+
+
+ )
+}
diff --git a/packages/dnb-eufemia/src/fragments/text-counter/__tests__/TextCounter.screenshot.test.ts b/packages/dnb-eufemia/src/fragments/text-counter/__tests__/TextCounter.screenshot.test.ts
new file mode 100644
index 00000000000..3d43ec727cc
--- /dev/null
+++ b/packages/dnb-eufemia/src/fragments/text-counter/__tests__/TextCounter.screenshot.test.ts
@@ -0,0 +1,30 @@
+/**
+ * Screenshot Test
+ * This file will not run on "test:staged" because we don't require any related files
+ */
+
+import {
+ makeScreenshot,
+ setupPageScreenshot,
+} from '../../../core/jest/jestSetupScreenshots'
+
+describe.each(['ui', 'sbanken'])('TextCounter for %s', (themeName) => {
+ setupPageScreenshot({
+ themeName,
+ url: '/uilib/components/fragments/text-counter/demos',
+ })
+
+ it('have to character counter downwards', async () => {
+ const screenshot = await makeScreenshot({
+ selector: '[data-visual-test="text-counter-down"]',
+ })
+ expect(screenshot).toMatchImageSnapshot()
+ })
+
+ it('have to character counter upwards', async () => {
+ const screenshot = await makeScreenshot({
+ selector: '[data-visual-test="text-counter-up"]',
+ })
+ expect(screenshot).toMatchImageSnapshot()
+ })
+})
diff --git a/packages/dnb-eufemia/src/fragments/text-counter/__tests__/TextCounter.test.tsx b/packages/dnb-eufemia/src/fragments/text-counter/__tests__/TextCounter.test.tsx
new file mode 100644
index 00000000000..eb223a59a9f
--- /dev/null
+++ b/packages/dnb-eufemia/src/fragments/text-counter/__tests__/TextCounter.test.tsx
@@ -0,0 +1,155 @@
+import React from 'react'
+import { render, waitFor } from '@testing-library/react'
+import { Provider } from '../../../shared'
+import TextCounter from '../TextCounter'
+
+describe('TextCounter', () => {
+ it('renders without provider', () => {
+ render()
+
+ const element = document.querySelector('.dnb-text-counter')
+ expect(element).toBeInTheDocument()
+ })
+
+ it('displays the correct count when variant is not specified', () => {
+ render()
+
+ const element = document.querySelector('.dnb-text-counter__message')
+ expect(element).toHaveTextContent('4 av 10 tegn gjenstår')
+ })
+
+ it('displays the correct count for variant "down"', () => {
+ render()
+
+ const element = document.querySelector('.dnb-text-counter__message')
+ expect(element).toHaveTextContent('6 av 10 tegn gjenstår')
+ })
+
+ it('displays the correct count for variant "up"', () => {
+ render()
+
+ const element = document.querySelector('.dnb-text-counter__message')
+ expect(element).toHaveTextContent('Du har brukt 4 av 10 tegn')
+ })
+
+ it('does disable aria-live when bypassAriaLive', () => {
+ const { rerender } = render()
+
+ const aria = document.querySelector('.dnb-aria-live')
+ expect(aria).toHaveAttribute('aria-live', 'polite')
+
+ rerender()
+
+ expect(aria).toHaveAttribute('aria-live', 'off')
+ })
+
+ it('handles empty text correctly', () => {
+ render()
+
+ const element = document.querySelector('.dnb-text-counter__message')
+ expect(element).toHaveTextContent('0 av 10 tegn gjenstår')
+ })
+
+ it('handles text length exceeding max correctly', () => {
+ render()
+
+ const element = document.querySelector('.dnb-text-counter__message')
+ expect(element).toHaveTextContent('10 av 10 tegn gjenstår')
+ })
+
+ it('handles negative max correctly', () => {
+ render()
+
+ const element = document.querySelector('.dnb-text-counter__message')
+ expect(element).toHaveTextContent('')
+ })
+
+ it('updates correctly when props change', () => {
+ const { rerender } = render()
+
+ const element = document.querySelector('.dnb-text-counter__message')
+ expect(element).toHaveTextContent('4 av 10 tegn gjenstår')
+
+ rerender()
+
+ expect(element).toHaveTextContent('7 av 10 tegn gjenstår')
+ })
+
+ it('supports lang and locale props', () => {
+ const { rerender } = render(
+
+
+
+ )
+
+ const element = document.querySelector('.dnb-text-counter__message')
+
+ expect(element).toHaveTextContent('4 of 10 characters remaining')
+
+ rerender(
+
+
+
+ )
+
+ expect(element).toHaveTextContent('4 of 10 characters remaining')
+
+ rerender(
+
+
+
+ )
+
+ expect(element).toHaveTextContent('4 of 10 characters remaining')
+
+ rerender(
+
+
+
+ )
+
+ expect(element).toHaveTextContent('4 av 10 tegn gjenstår')
+ })
+
+ it('should render AriaLive message', async () => {
+ const { rerender } = render(
+
+ )
+
+ const counter = document.querySelector('.dnb-text-counter__message')
+ const ariaLive = document.querySelector('.dnb-aria-live')
+
+ expect(counter).toHaveTextContent('0 av 8 tegn gjenstår')
+ expect(ariaLive).toHaveTextContent('')
+
+ rerender()
+
+ expect(counter).toHaveTextContent('3 av 8 tegn gjenstår')
+
+ await waitFor(() => {
+ expect(ariaLive).toHaveTextContent('3 av 8 tegn gjenstår')
+ })
+
+ rerender(
+
+ )
+
+ expect(counter).toHaveTextContent('8 of 8 characters remaining')
+
+ await waitFor(() => {
+ expect(ariaLive).toHaveTextContent('')
+ })
+ })
+
+ it('supports spacing props', () => {
+ render()
+
+ const element = document.querySelector('.dnb-text-counter')
+ expect(element).toHaveClass('dnb-space__top--large')
+ })
+})
diff --git a/packages/dnb-eufemia/src/fragments/text-counter/__tests__/__image_snapshots__/textcounter-for-sbanken-have-to-character-counter-downwards.snap.png b/packages/dnb-eufemia/src/fragments/text-counter/__tests__/__image_snapshots__/textcounter-for-sbanken-have-to-character-counter-downwards.snap.png
new file mode 100644
index 00000000000..bd80baf94ea
Binary files /dev/null and b/packages/dnb-eufemia/src/fragments/text-counter/__tests__/__image_snapshots__/textcounter-for-sbanken-have-to-character-counter-downwards.snap.png differ
diff --git a/packages/dnb-eufemia/src/fragments/text-counter/__tests__/__image_snapshots__/textcounter-for-sbanken-have-to-character-counter-upwards.snap.png b/packages/dnb-eufemia/src/fragments/text-counter/__tests__/__image_snapshots__/textcounter-for-sbanken-have-to-character-counter-upwards.snap.png
new file mode 100644
index 00000000000..19e3b378f43
Binary files /dev/null and b/packages/dnb-eufemia/src/fragments/text-counter/__tests__/__image_snapshots__/textcounter-for-sbanken-have-to-character-counter-upwards.snap.png differ
diff --git a/packages/dnb-eufemia/src/fragments/text-counter/__tests__/__image_snapshots__/textcounter-for-ui-have-to-character-counter-downwards.snap.png b/packages/dnb-eufemia/src/fragments/text-counter/__tests__/__image_snapshots__/textcounter-for-ui-have-to-character-counter-downwards.snap.png
new file mode 100644
index 00000000000..67a454879e1
Binary files /dev/null and b/packages/dnb-eufemia/src/fragments/text-counter/__tests__/__image_snapshots__/textcounter-for-ui-have-to-character-counter-downwards.snap.png differ
diff --git a/packages/dnb-eufemia/src/fragments/text-counter/__tests__/__image_snapshots__/textcounter-for-ui-have-to-character-counter-upwards.snap.png b/packages/dnb-eufemia/src/fragments/text-counter/__tests__/__image_snapshots__/textcounter-for-ui-have-to-character-counter-upwards.snap.png
new file mode 100644
index 00000000000..efdf7d2a7d1
Binary files /dev/null and b/packages/dnb-eufemia/src/fragments/text-counter/__tests__/__image_snapshots__/textcounter-for-ui-have-to-character-counter-upwards.snap.png differ
diff --git a/packages/dnb-eufemia/src/fragments/text-counter/index.ts b/packages/dnb-eufemia/src/fragments/text-counter/index.ts
new file mode 100644
index 00000000000..e3b60b61c8e
--- /dev/null
+++ b/packages/dnb-eufemia/src/fragments/text-counter/index.ts
@@ -0,0 +1,6 @@
+/**
+ * Component Entry
+ *
+ */
+
+export * from './TextCounter'
diff --git a/packages/dnb-eufemia/src/fragments/text-counter/style.ts b/packages/dnb-eufemia/src/fragments/text-counter/style.ts
new file mode 100644
index 00000000000..2098f547f84
--- /dev/null
+++ b/packages/dnb-eufemia/src/fragments/text-counter/style.ts
@@ -0,0 +1,6 @@
+/**
+ * Web Style Import
+ *
+ */
+
+import './style/dnb-text-counter.scss'
diff --git a/packages/dnb-eufemia/src/fragments/text-counter/style/dnb-text-counter.scss b/packages/dnb-eufemia/src/fragments/text-counter/style/dnb-text-counter.scss
new file mode 100644
index 00000000000..02da6ddd3d9
--- /dev/null
+++ b/packages/dnb-eufemia/src/fragments/text-counter/style/dnb-text-counter.scss
@@ -0,0 +1,3 @@
+.dnb-text-counter {
+ word-break: break-word;
+}
diff --git a/packages/dnb-eufemia/src/fragments/text-counter/style/index.js b/packages/dnb-eufemia/src/fragments/text-counter/style/index.js
new file mode 100644
index 00000000000..ff29f520e0a
--- /dev/null
+++ b/packages/dnb-eufemia/src/fragments/text-counter/style/index.js
@@ -0,0 +1,6 @@
+/**
+ * Web Style Import
+ *
+ */
+
+import './dnb-textarea.scss'
diff --git a/packages/dnb-eufemia/src/fragments/text-counter/style/themes/dnb-text-counter-theme-sbanken.scss b/packages/dnb-eufemia/src/fragments/text-counter/style/themes/dnb-text-counter-theme-sbanken.scss
new file mode 100644
index 00000000000..629d74edf9f
--- /dev/null
+++ b/packages/dnb-eufemia/src/fragments/text-counter/style/themes/dnb-text-counter-theme-sbanken.scss
@@ -0,0 +1,6 @@
+.dnb-text-counter {
+ &__message {
+ font-size: var(--font-size-small);
+ color: var(--sb-color-gray-dark);
+ }
+}
diff --git a/packages/dnb-eufemia/src/fragments/text-counter/style/themes/dnb-text-counter-theme-ui.scss b/packages/dnb-eufemia/src/fragments/text-counter/style/themes/dnb-text-counter-theme-ui.scss
new file mode 100644
index 00000000000..1450971b552
--- /dev/null
+++ b/packages/dnb-eufemia/src/fragments/text-counter/style/themes/dnb-text-counter-theme-ui.scss
@@ -0,0 +1,6 @@
+.dnb-text-counter {
+ &__message {
+ font-size: var(--font-size-small);
+ color: var(--color-black-55);
+ }
+}
diff --git a/packages/dnb-eufemia/src/fragments/text-counter/style/themes/ui.js b/packages/dnb-eufemia/src/fragments/text-counter/style/themes/ui.js
new file mode 100644
index 00000000000..f18017bc590
--- /dev/null
+++ b/packages/dnb-eufemia/src/fragments/text-counter/style/themes/ui.js
@@ -0,0 +1,6 @@
+/**
+ * Imports the default theme
+ *
+ */
+
+import './dnb-textarea-theme-ui.scss'
diff --git a/packages/dnb-eufemia/src/shared/locales/en-GB.js b/packages/dnb-eufemia/src/shared/locales/en-GB.js
index eb0f7cc69ad..f39291eb14d 100644
--- a/packages/dnb-eufemia/src/shared/locales/en-GB.js
+++ b/packages/dnb-eufemia/src/shared/locales/en-GB.js
@@ -1,7 +1,8 @@
export default {
'en-GB': {
- Textarea: {
- characterCounter: '%count of %max characters remaining',
+ TextCounter: {
+ characterDown: '%count of %max characters remaining',
+ characterUp: 'You have used %count of %max characters',
},
TimelineItem: {
alt_label_completed: 'Complete',
diff --git a/packages/dnb-eufemia/src/shared/locales/nb-NO.js b/packages/dnb-eufemia/src/shared/locales/nb-NO.js
index 98154614ce8..127a1f2be21 100644
--- a/packages/dnb-eufemia/src/shared/locales/nb-NO.js
+++ b/packages/dnb-eufemia/src/shared/locales/nb-NO.js
@@ -1,7 +1,8 @@
export default {
'nb-NO': {
- Textarea: {
- characterCounter: '%count av %max gjenstående tegn',
+ TextCounter: {
+ characterDown: '%count av %max tegn gjenstår',
+ characterUp: 'Du har brukt %count av %max tegn',
},
TimelineItem: {
alt_label_completed: 'Utført',
diff --git a/packages/dnb-eufemia/src/style/dnb-ui-fragments.scss b/packages/dnb-eufemia/src/style/dnb-ui-fragments.scss
index 5593cdf420a..87b33905f52 100644
--- a/packages/dnb-eufemia/src/style/dnb-ui-fragments.scss
+++ b/packages/dnb-eufemia/src/style/dnb-ui-fragments.scss
@@ -7,3 +7,4 @@
@import './core/utilities.scss';
@import '../fragments/drawer-list/style/dnb-drawer-list.scss';
@import '../fragments/scroll-view/style/dnb-scroll-view.scss';
+@import '../fragments/text-counter/style/dnb-text-counter.scss';
diff --git a/packages/dnb-eufemia/src/style/themes/theme-eiendom/eiendom-theme-components.scss b/packages/dnb-eufemia/src/style/themes/theme-eiendom/eiendom-theme-components.scss
index 08985a9686a..644ce1ca173 100644
--- a/packages/dnb-eufemia/src/style/themes/theme-eiendom/eiendom-theme-components.scss
+++ b/packages/dnb-eufemia/src/style/themes/theme-eiendom/eiendom-theme-components.scss
@@ -58,4 +58,5 @@ $fonts-path: '../../../../assets/fonts/dnb' !default;
@import '../../../components/upload/style/themes/dnb-upload-theme-ui.scss';
@import '../../../fragments/drawer-list/style/themes/dnb-drawer-list-theme-ui.scss';
@import '../../../fragments/scroll-view/style/themes/dnb-scroll-view-theme-ui.scss';
+@import '../../../fragments/text-counter/style/themes/dnb-text-counter-theme-ui.scss';
@import './eiendom-theme-forms.scss';
diff --git a/packages/dnb-eufemia/src/style/themes/theme-sbanken/sbanken-theme-components.scss b/packages/dnb-eufemia/src/style/themes/theme-sbanken/sbanken-theme-components.scss
index 5401bcf587e..e198b7b7d39 100644
--- a/packages/dnb-eufemia/src/style/themes/theme-sbanken/sbanken-theme-components.scss
+++ b/packages/dnb-eufemia/src/style/themes/theme-sbanken/sbanken-theme-components.scss
@@ -45,6 +45,7 @@ $fonts-path: '../../../../assets/fonts/dnb' !default;
@import '../../../components/tooltip/style/themes/dnb-tooltip-theme-sbanken.scss';
@import '../../../components/upload/style/themes/dnb-upload-theme-sbanken.scss';
@import '../../../fragments/drawer-list/style/themes/dnb-drawer-list-theme-sbanken.scss';
+@import '../../../fragments/text-counter/style/themes/dnb-text-counter-theme-sbanken.scss';
@import '../../../components/autocomplete/style/themes/dnb-autocomplete-theme-ui.scss';
@import '../../../components/badge/style/themes/dnb-badge-theme-ui.scss';
@import '../../../components/card/style/themes/dnb-card-theme-ui.scss';
diff --git a/packages/dnb-eufemia/src/style/themes/theme-ui/ui-theme-components.scss b/packages/dnb-eufemia/src/style/themes/theme-ui/ui-theme-components.scss
index 74d5a507345..1d68b0d0382 100644
--- a/packages/dnb-eufemia/src/style/themes/theme-ui/ui-theme-components.scss
+++ b/packages/dnb-eufemia/src/style/themes/theme-ui/ui-theme-components.scss
@@ -54,4 +54,5 @@ $fonts-path: '../../../../assets/fonts/dnb' !default;
@import '../../../components/upload/style/themes/dnb-upload-theme-ui.scss';
@import '../../../fragments/drawer-list/style/themes/dnb-drawer-list-theme-ui.scss';
@import '../../../fragments/scroll-view/style/themes/dnb-scroll-view-theme-ui.scss';
+@import '../../../fragments/text-counter/style/themes/dnb-text-counter-theme-ui.scss';
@import './ui-theme-forms.scss';