From d88664d6138ab14cb175acfa7a1e4b18844f514e Mon Sep 17 00:00:00 2001 From: eirikbacker Date: Tue, 1 Oct 2024 12:29:30 +0200 Subject: [PATCH 01/34] feat: input component --- packages/css/index.css | 1 + packages/css/input.css | 151 ++++++++++++++++++ .../react/src/components/form/Input/Input.mdx | 81 ++++++++++ .../components/form/Input/Input.stories.tsx | 73 +++++++++ .../src/components/form/Input/Input.test.tsx | 144 +++++++++++++++++ .../react/src/components/form/Input/Input.tsx | 130 +++++++++++++++ .../react/src/components/form/Input/index.ts | 1 + .../src/components/form/Input/useInput.ts | 49 ++++++ packages/react/src/components/index.ts | 1 + 9 files changed, 631 insertions(+) create mode 100644 packages/css/input.css create mode 100644 packages/react/src/components/form/Input/Input.mdx create mode 100644 packages/react/src/components/form/Input/Input.stories.tsx create mode 100644 packages/react/src/components/form/Input/Input.test.tsx create mode 100644 packages/react/src/components/form/Input/Input.tsx create mode 100644 packages/react/src/components/form/Input/index.ts create mode 100644 packages/react/src/components/form/Input/useInput.ts diff --git a/packages/css/index.css b/packages/css/index.css index db4f67e92e..120bbd96f5 100644 --- a/packages/css/index.css +++ b/packages/css/index.css @@ -17,6 +17,7 @@ @import url('./search.css') layer(ds.components); @import url('./select.css') layer(ds.components); @import url('./textfield.css') layer(ds.components); +@import url('./input.css') layer(ds.components); @import url('./textarea.css') layer(ds.components); @import url('./helptext.css') layer(ds.components); @import url('./modal.css') layer(ds.components); diff --git a/packages/css/input.css b/packages/css/input.css new file mode 100644 index 0000000000..713bdeb73b --- /dev/null +++ b/packages/css/input.css @@ -0,0 +1,151 @@ +.ds-input { + display: grid; + gap: var(--ds-spacing-2); +} + +.ds-input__adornment { + color: var(--ds-color-neutral-text-subtle); + background: var(--ds-color-neutral-background-subtle); + padding: 9px var(--ds-spacing-4); + border-radius: var(--ds-border-radius-md); + border: solid 1px var(--ds-color-neutral-border-default); + box-sizing: border-box; + display: inline-block; +} + +.ds-input__input { + font-family: inherit; + position: relative; + box-sizing: border-box; + flex: 0 1 auto; + width: 100%; + appearance: none; + padding: 0 var(--ds-spacing-3); + border: solid 1px var(--ds-color-neutral-border-default); + background: var(--ds-color-neutral-background-default); + color: var(--ds-color-neutral-text-default); + border-radius: var(--ds-border-radius-md); +} + +.ds-input__input:disabled { + cursor: not-allowed; +} + +.ds-input--readonly .ds-input__input { + background: var(--ds-color-neutral-background-subtle); + border-color: var(--ds-color-neutral-border-strong); +} + +.ds-input__field { + display: flex; + align-items: stretch; + border-radius: var(--ds-border-radius-md); +} + +.ds-input__field > *:first-child { + border-top-left-radius: var(--ds-border-radius-md); + border-bottom-left-radius: var(--ds-border-radius-md); +} + +.ds-input__field > *:last-child { + border-top-right-radius: var(--ds-border-radius-md); + border-bottom-right-radius: var(--ds-border-radius-md); +} + +.ds-input--sm .ds-input__adornment { + padding: var(--ds-sizing-2) var(--ds-spacing-3); +} + +.ds-input--md .ds-input__adornment { + padding: 0.65rem var(--ds-spacing-4); +} + +.ds-input--lg .ds-input__adornment { + padding: 0.85rem var(--ds-spacing-5); +} + +.ds-input--sm .ds-input__field { + height: var(--ds-sizing-10); +} + +.ds-input--md .ds-input__field { + height: var(--ds-sizing-12); +} + +.ds-input--lg .ds-input__field { + height: var(--ds-sizing-14); +} + +.ds-input--sm .ds-input__input { + padding: 0 var(--ds-spacing-2); +} + +.ds-input--md .ds-input__input { + padding: 0 var(--ds-spacing-3); +} + +.ds-input--lg .ds-input__input { + padding: 0 var(--ds-spacing-4); +} + +.ds-input__label { + min-width: min-content; + display: inline-flex; + flex-direction: row; + gap: var(--ds-spacing-1); + align-items: center; +} + +.ds-input__description { + color: var(--ds-color-neutral-text-subtle); + margin-top: calc(var(--ds-spacing-2) * -1); +} + +.ds-input:has(.ds-input__input:disabled) { + opacity: var(--ds-disabled-opacity); +} + +.ds-input--error .ds-input__input:not(:focus-visible) { + border-color: var(--ds-color-danger-border-default); + box-shadow: inset 0 0 0 1px var(--ds-color-danger-border-default); +} + +@media (hover: hover) and (pointer: fine) { + .ds-input__input:not(:focus-visible, :disabled, [aria-disabled]):hover { + border-color: var(--ds-color-accent-border-strong); + box-shadow: inset 0 0 0 1px var(--ds-color-accent-border-strong); + } +} + +.ds-input__input--with-prefix { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.ds-input__input--with-suffix { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.ds-input__prefix { + border-right: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + white-space: nowrap; +} + +.ds-input__suffix { + border-left: 0; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + white-space: nowrap; +} + +.ds-input__readonly__icon { + height: 1.2em; + width: 1.2em; +} + +.ds-input__error-message:empty { + display: none; +} diff --git a/packages/react/src/components/form/Input/Input.mdx b/packages/react/src/components/form/Input/Input.mdx new file mode 100644 index 0000000000..297c31891d --- /dev/null +++ b/packages/react/src/components/form/Input/Input.mdx @@ -0,0 +1,81 @@ +import { Meta, Canvas, Controls, Primary } from '@storybook/blocks'; + +import * as InputStories from './Input.stories'; + + + +# Input + +`Input` kalles tekstfelt på norsk. Det er et inndatafelt som for eksempel gir brukerne mulighet til å skrive korte tekster eller tall. + + + + +## Prefix/Suffix + +Prefixer og suffixer er nyttige for å vise enheter, valuta eller andre typer informasjon som er relevant for feltet. +Du skal **ikke** bruke disse alene, siden skjermlesere ikke leser dem opp. +Det er viktig at samme informasjon som vises i prefixet eller suffixet også er inkludert i ledeteksten. + + + +## Antall tegn + + + +## Kontrollert + + + +## Html Size + + + +## Retningslinjer for når du skal bruke `Input` + +Vi bruker tekstfelt når vi vil gi brukeren mulighet til å skrive tekst/svar på maks. én linje, Det kan for eksempel være navn eller telefonnummer. + +Passer til å + +- gi mulighet for korte tekster eller svar +- legge inn tall, for eksempel et telefonnummer + +Passer ikke til å + +- gi lengre svar, bruk heller `Textarea` +- legge inn formaterte data, som markdown +
+ +### Plassering av ledeteksten + +Ledeteksten og en eventuell beskrivelse skal alltid stå over tekstfeltet. Da er de lette å se på små skjermer og hindres ikke av eventuelle feilmeldinger. + +### Unngå plassholdertekster + +Plassholdertekster forsvinner når brukerne skriver i feltet. Det er derfor bedre å inkludere hint og viktig informasjon i selve ledeteksten eller den tilhørende beskrivelsen. + +### Tilpass bredden på tekstfeltet + +Tilpass bredden til det brukerne skal skrive inn, kort bredde til telefonnummer og bredere til stedsnavn. Ulik bredde på feltene gjør det enklere å navigere i skjemaer som har mange felter. + +### Inndata og formatering + +- Bruk `autoComplete` for felter som mottar personlig informasjon. Hvis feltet skal be om personopplysninger om en annen person enn brukeren, må du skru `autoComplete` av (WCAG 1.3.5). +- Bruk gjerne inndatatyper som viser hva du ber om, for eksempel telefonnummer og e-post. Slike inndatatyper gir mobilbrukere et tastatur som passer til det de skal angi, for eksempel et numerisk tastatur for telefonnummer, men de kan også utløse validering på klientsiden. +- Godta det meste av inndata fra brukerne, så lenge det er forståelig. Eksempler kan være kontonummer med punktum, telefonnummer med mellomrom eller mellomrom på slutten av en e-postadresse. +- Pass på at brukerne ser inndata som formateres automatisk, men uten at det forstyrrer dem mens de fyller ut. +- Ikke bruk bare store bokstaver eller kursiv tekst i ledeteksten. Det er vanskelig å lese. + +## Tekst i komponenten + +Det skal alltid være ledetekst på `Input`. I spesielle tilfeller kan vi skjule ledeteksten med `hidelabel`. Det kan for eksempel være i tabeller, hvis feltet får ledeteksten fra tabelloverskriften. Selv om vi har tenkt å skjule ledeteksten, må vi alltid skrive en ledetekst som gir mening, siden den leses opp av skjermlesere. + +## Tilgjengelighet + +### Ikke bruk deaktiverte felt + +Ikke bruk deaktivert tilstand (disabled state) på tekstfelt. Tenk heller over om du trenger å vise feltet i det hele tatt, eller om du heller kan skrive informasjonen ut i ren tekst eller bruke Read Only. + +### Prefiks og suffiks + +Prefiks og suffiks er et ekstra visuelt hjelpemiddel, som blir ignorert av skjermlesere. Vi må alltid ha en beskrivende ledetekst. Prefiks og suffiks er plassert utenfor inndatafeltene de tilhører. Da unngår vi at de ikke skaper trøbbel i noen nettlesere, som kan sette inn et ikon i inndatafeltet (for eksempel ikoner for å vise eller lage passord). diff --git a/packages/react/src/components/form/Input/Input.stories.tsx b/packages/react/src/components/form/Input/Input.stories.tsx new file mode 100644 index 0000000000..406e96b2cd --- /dev/null +++ b/packages/react/src/components/form/Input/Input.stories.tsx @@ -0,0 +1,73 @@ +import type { Meta, StoryFn, StoryObj } from '@storybook/react'; +import { useState } from 'react'; + +import { Button, Paragraph } from '../..'; + +import { Input } from '.'; + +type Story = StoryObj; + +export default { + title: 'Komponenter/Input', + component: Input, +} as Meta; + +export const Preview: Story = { + args: { + label: 'Label', + disabled: false, + readOnly: false, + size: 'md', + description: '', + error: '', + }, +}; + +export const WithCharacterCounter: Story = { + args: { + label: 'Label', + characterLimit: { + maxCount: 5, + }, + }, +}; + +export const HtmlSize: Story = { + args: { + label: 'Label', + htmlSize: 10, + }, +}; + +export const Adornments: Story = { + args: { + prefix: 'NOK', + suffix: 'pr. mnd', + size: 'md', + label: 'Hvor mange kroner koster det per måned?', + }, +}; + +export const Controlled: StoryFn = () => { + const [value, setValue] = useState(); + return ( + <> + Du har skrevet inn: {value} +
+ setValue(e.target.value)} + /> + +
+ + ); +}; diff --git a/packages/react/src/components/form/Input/Input.test.tsx b/packages/react/src/components/form/Input/Input.test.tsx new file mode 100644 index 0000000000..42f58fa8b9 --- /dev/null +++ b/packages/react/src/components/form/Input/Input.test.tsx @@ -0,0 +1,144 @@ +import { render as renderRtl, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { act } from 'react'; + +import type { InputProps } from './Input'; +import { Input } from './Input'; + +const user = userEvent.setup(); + +describe('Input', () => { + test('has correct value and label', () => { + render({ value: 'test', label: 'label' }); + expect(screen.getByLabelText('label')).toBeDefined(); + expect(screen.getByDisplayValue('test')).toBeDefined(); + }); + + test('has correct description', () => { + render({ description: 'description' }); + expect( + screen.getByRole('textbox', { description: 'description' }), + ).toBeDefined(); + }); + + test('has correct description and label when label is hidden', () => { + render({ description: 'description', label: 'label', hideLabel: true }); + + expect(screen.getByLabelText('label')).toBeDefined(); + expect( + screen.getByRole('textbox', { description: 'description' }), + ).toBeDefined(); + }); + + test('is invalid with correct error message', () => { + render({ error: 'error-message' }); + + const input = screen.getByRole('textbox', { description: 'error-message' }); + expect(input).toBeDefined(); + expect(input).toBeInvalid(); + }); + + test('is invalid with correct error message from errorId', () => { + renderRtl( + <> + my error message + + , + ); + + const input = screen.getByRole('textbox', { + description: 'my error message', + }); + expect(input).toBeDefined(); + expect(input).toBeInvalid(); + }); + + it('should have max allowed characters label for screen readers', () => { + render({ + characterLimit: { + maxCount: 10, + srLabel: 'Max 10 characters is allowed', + label: (count: number) => `${count} characters remaining`, + }, + }); + const screenReaderText = screen.getByText('Max 10 characters is allowed'); + expect(screenReaderText).toBeInTheDocument(); + }); + + it('should countdown remaining characters', async () => { + const user = userEvent.setup(); + render({ + label: 'First name', + characterLimit: { + maxCount: 10, + label: (count: number) => `${count} characters remaining`, + srLabel: 'characters remaining', + }, + }); + const inputField = screen.getByLabelText('First name'); + await act(async () => await user.type(inputField, 'Peter')); + expect(screen.getByText('5 characters remaining')).toBeInTheDocument(); + }); + + it('Triggers onBlur event when field loses focus', async () => { + const onBlur = vi.fn(); + render({ onBlur }); + const element = screen.getByRole('textbox'); + await act(async () => await user.click(element)); + expect(element).toHaveFocus(); + await act(async () => await user.tab()); + expect(onBlur).toHaveBeenCalledTimes(1); + }); + + it('Triggers onChange event for each keystroke', async () => { + const onChange = vi.fn(); + const data = 'test'; + render({ onChange }); + const element = screen.getByRole('textbox'); + await act(async () => await user.click(element)); + expect(element).toHaveFocus(); + await act(async () => await user.keyboard(data)); + expect(onChange).toHaveBeenCalledTimes(data.length); + }); + + it('Sets given id on input field', () => { + const id = 'some-unique-id'; + render({ id }); + expect(screen.getByRole('textbox')).toHaveAttribute('id', id); + }); + + it('Focuses on input field when label is clicked and id is not given', async () => { + const label = 'Lorem ipsum'; + render({ label }); + await act(async () => await user.click(screen.getByText(label))); + expect(screen.getByRole('textbox')).toHaveFocus(); + }); + + it('Focuses on input field when label is clicked and id is given', async () => { + const label = 'Lorem ipsum'; + render({ id: 'some-unique-id', label }); + await act(async () => await user.click(screen.getByText(label))); + expect(screen.getByRole('textbox')).toHaveFocus(); + }); + + it('Has type attribute set to "text" by default', () => { + render(); + expect(screen.getByRole('textbox')).toHaveAttribute('type', 'text'); + }); + + it('Has given type attribute if set', () => { + const type = 'tel'; + render({ type }); + expect(screen.getByRole('textbox')).toHaveAttribute('type', type); + }); +}); + +const render = (props: Partial = {}) => + renderRtl( + , + ); diff --git a/packages/react/src/components/form/Input/Input.tsx b/packages/react/src/components/form/Input/Input.tsx new file mode 100644 index 0000000000..c532062c35 --- /dev/null +++ b/packages/react/src/components/form/Input/Input.tsx @@ -0,0 +1,130 @@ +import { PadlockLockedFillIcon } from '@navikt/aksel-icons'; +import cl from 'clsx/lite'; +import type { InputHTMLAttributes, ReactNode } from 'react'; +import { forwardRef, useId, useState } from 'react'; + +import { omit } from '../../../utilities'; +import type { CharacterLimitProps } from '../CharacterCounter'; +import type { FormFieldProps } from '../useFormField'; + +import { useInput } from './useInput'; + +export type InputProps = { + /** Label */ + label?: ReactNode; + /** Visually hides `label` and `description` (still available for screen readers) */ + hideLabel?: boolean; + /** + * Changes field size and paddings + * @default md + */ + size?: 'sm' | 'md' | 'lg'; + /** Prefix for field. */ + prefix?: string; + /** Suffix for field. */ + suffix?: string; + /** Supported `input` types */ + type?: + | 'date' + | 'datetime-local' + | 'email' + | 'file' + | 'month' + | 'number' + | 'password' + | 'search' + | 'tel' + | 'text' + | 'time' + | 'url' + | 'week'; + /** + * The characterLimit function calculates remaining characters based on `maxCount` + * + * Provide a `label` function that takes count as parameter and returns a message. + * + * Use `srLabel` to describe `maxCount` for screen readers. + * + * Defaults to Norwegian if no labels are provided. + */ + characterLimit?: CharacterLimitProps; + /** Exposes the HTML `size` attribute. + * @default 20 + */ + htmlSize?: number; +} & Omit & + Omit, 'size'>; + +/** Text input field + * + * @example + * ```tsx + * + * ``` + */ +export const Input = forwardRef( + function Input(props, ref) { + const { + label, + description, + suffix, + prefix, + style, + characterLimit, + hideLabel, + type = 'text', + htmlSize = 20, + className, + ...rest + } = props; + + const { + inputProps, + descriptionId, + hasError, + errorId, + size = 'md', + readOnly, + } = useInput(props); + + const [inputValue, setInputValue] = useState( + props.value || props.defaultValue, + ); + const characterLimitId = `Input-charactercount-${useId()}`; + const hasCharacterLimit = characterLimit != null; + + const describedBy = + cl( + inputProps['aria-describedby'], + hasCharacterLimit && characterLimitId, + ) || undefined; + + return ( + { + inputProps?.onChange?.(e); + setInputValue(e.target.value); + }} + /> + ); + }, +); + +export const InputAdornments = forwardRef( + function InputAdornments() { + return null; + }, +); diff --git a/packages/react/src/components/form/Input/index.ts b/packages/react/src/components/form/Input/index.ts new file mode 100644 index 0000000000..ba9fe7ebc6 --- /dev/null +++ b/packages/react/src/components/form/Input/index.ts @@ -0,0 +1 @@ +export * from './Input'; diff --git a/packages/react/src/components/form/Input/useInput.ts b/packages/react/src/components/form/Input/useInput.ts new file mode 100644 index 0000000000..e7f5a42ba5 --- /dev/null +++ b/packages/react/src/components/form/Input/useInput.ts @@ -0,0 +1,49 @@ +import type { InputHTMLAttributes } from 'react'; +import { useContext } from 'react'; + +import { FieldsetContext } from '../Fieldset/FieldsetContext'; +import type { FormField } from '../useFormField'; +import { useFormField } from '../useFormField'; + +import type { InputProps } from './Input'; + +type UseInput = (props: InputProps) => FormField & { + inputProps?: Pick< + InputHTMLAttributes, + 'readOnly' | 'type' | 'name' | 'required' | 'onClick' | 'onChange' + >; +}; +/** Handles props for `Input` in context with `Fieldset` */ +export const useInput: UseInput = (props) => { + const fieldset = useContext(FieldsetContext); + const { + inputProps, + readOnly, + size = fieldset?.size ?? 'md', + ...rest + } = useFormField(props, 'Input'); + + return { + ...rest, + readOnly, + size, + inputProps: { + ...inputProps, + readOnly, + onClick: (e) => { + if (readOnly) { + e.preventDefault(); + return; + } + props?.onClick?.(e); + }, + onChange: (e) => { + if (readOnly) { + e.preventDefault(); + return; + } + props?.onChange?.(e); + }, + }, + }; +}; diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index e38df67110..8d3b084e17 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -25,6 +25,7 @@ export * from './form/Fieldset'; export * from './form/Switch'; export * from './form/Textfield'; export * from './form/Textarea'; +export * from './form/Input'; export * from './Tabs'; export * from './ToggleGroup'; export * from './Popover'; From 595f45a9b75d02409d7c480cb07703594bacde62 Mon Sep 17 00:00:00 2001 From: eirikbacker Date: Tue, 1 Oct 2024 21:44:41 +0200 Subject: [PATCH 02/34] fix(Input): simplify css --- packages/css/input.css | 188 ++++++------------ .../react/src/components/form/Input/Input.mdx | 4 - .../components/form/Input/Input.stories.tsx | 67 ++++--- .../react/src/components/form/Input/Input.tsx | 143 ++++--------- .../src/components/form/Input/useInput.ts | 49 ----- 5 files changed, 143 insertions(+), 308 deletions(-) delete mode 100644 packages/react/src/components/form/Input/useInput.ts diff --git a/packages/css/input.css b/packages/css/input.css index 713bdeb73b..db89cd9e5b 100644 --- a/packages/css/input.css +++ b/packages/css/input.css @@ -1,151 +1,93 @@ .ds-input { - display: grid; - gap: var(--ds-spacing-2); -} - -.ds-input__adornment { - color: var(--ds-color-neutral-text-subtle); - background: var(--ds-color-neutral-background-subtle); - padding: 9px var(--ds-spacing-4); + appearance: none; + background: var(--ds-color-neutral-background-default); border-radius: var(--ds-border-radius-md); - border: solid 1px var(--ds-color-neutral-border-default); + border: var(--dsc-input-addons-border-width, 1px) solid var(--dsc-input-addons-border-color, var(--ds-color-neutral-border-default)); box-sizing: border-box; - display: inline-block; -} - -.ds-input__input { + color: var(--ds-color-neutral-text-default); + display: block; + flex: 0 1 auto; /* Prepare for placing inside ds-input-addons */ font-family: inherit; - position: relative; - box-sizing: border-box; - flex: 0 1 auto; - width: 100%; - appearance: none; + height: var(--ds-sizing-12); padding: 0 var(--ds-spacing-3); - border: solid 1px var(--ds-color-neutral-border-default); - background: var(--ds-color-neutral-background-default); - color: var(--ds-color-neutral-text-default); - border-radius: var(--ds-border-radius-md); -} - -.ds-input__input:disabled { - cursor: not-allowed; -} + position: relative; /* Place focus ring on top */ -.ds-input--readonly .ds-input__input { - background: var(--ds-color-neutral-background-subtle); - border-color: var(--ds-color-neutral-border-strong); -} + @composes ds-focus from './utilities.css'; + @composes ds-body-text--md from './utilities.css'; -.ds-input__field { - display: flex; - align-items: stretch; - border-radius: var(--ds-border-radius-md); -} - -.ds-input__field > *:first-child { - border-top-left-radius: var(--ds-border-radius-md); - border-bottom-left-radius: var(--ds-border-radius-md); -} + &[data-size='sm'] { + @composes ds-body-text--sm from './utilities.css'; -.ds-input__field > *:last-child { - border-top-right-radius: var(--ds-border-radius-md); - border-bottom-right-radius: var(--ds-border-radius-md); -} - -.ds-input--sm .ds-input__adornment { - padding: var(--ds-sizing-2) var(--ds-spacing-3); -} + height: var(--ds-sizing-10); + padding: 0 var(--ds-spacing-2); + } -.ds-input--md .ds-input__adornment { - padding: 0.65rem var(--ds-spacing-4); -} + &[data-size='lg'] { + @composes ds-body-text--lg from './utilities.css'; -.ds-input--lg .ds-input__adornment { - padding: 0.85rem var(--ds-spacing-5); -} + padding: 0 var(--ds-spacing-2); + height: var(--ds-sizing-14); + } -.ds-input--sm .ds-input__field { - height: var(--ds-sizing-10); -} + &:not([size]) { + width: 100%; + } -.ds-input--md .ds-input__field { - height: var(--ds-sizing-12); -} + &:disabled, + &[aria-disabled='true'] { + cursor: not-allowed; + opacity: var(--ds-disabled-opacity); + } -.ds-input--lg .ds-input__field { - height: var(--ds-sizing-14); -} + &:read-only { + background: var(--ds-color-neutral-background-subtle); + border-color: var(--ds-color-neutral-border-strong); + } -.ds-input--sm .ds-input__input { - padding: 0 var(--ds-spacing-2); -} + &[aria-invalid='true']:not(:focus-visible) { + border-color: var(--ds-color-danger-border-default); + box-shadow: inset 0 0 0 1px var(--ds-color-danger-border-default); + } -.ds-input--md .ds-input__input { - padding: 0 var(--ds-spacing-3); + @media (hover: hover) and (pointer: fine) { + &:not(:focus-visible, :disabled, [aria-disabled]):hover { + border-color: var(--ds-color-accent-border-strong); + box-shadow: inset 0 0 0 1px var(--ds-color-accent-border-strong); + } + } } -.ds-input--lg .ds-input__input { - padding: 0 var(--ds-spacing-4); -} +.ds-input-addons { + --dsc-input-addons-border-color: var(--ds-color-neutral-border-default); + --dsc-input-addons-border-width: 1px; + --dsc-input-addons-padding: var(--ds-spacing-4); -.ds-input__label { - min-width: min-content; - display: inline-flex; - flex-direction: row; - gap: var(--ds-spacing-1); align-items: center; -} - -.ds-input__description { + background: var(--ds-color-neutral-background-subtle); + border-radius: var(--ds-border-radius-md); + box-shadow: inset 0 0 0 var(--dsc-input-addons-border-width) var(--dsc-input-addons-border-color); /* Using box-shadow inset to allow input to render over */ + box-sizing: border-box; color: var(--ds-color-neutral-text-subtle); - margin-top: calc(var(--ds-spacing-2) * -1); -} - -.ds-input:has(.ds-input__input:disabled) { - opacity: var(--ds-disabled-opacity); -} + display: flex; + gap: var(--dsc-input-addons-padding); + padding-inline: var(--dsc-input-addons-padding); + white-space: nowrap; -.ds-input--error .ds-input__input:not(:focus-visible) { - border-color: var(--ds-color-danger-border-default); - box-shadow: inset 0 0 0 1px var(--ds-color-danger-border-default); -} + @composes ds-body-text--md from './utilities.css'; -@media (hover: hover) and (pointer: fine) { - .ds-input__input:not(:focus-visible, :disabled, [aria-disabled]):hover { - border-color: var(--ds-color-accent-border-strong); - box-shadow: inset 0 0 0 1px var(--ds-color-accent-border-strong); + .ds-input { + border-radius: 0; } -} - -.ds-input__input--with-prefix { - border-top-left-radius: 0; - border-bottom-left-radius: 0; -} - -.ds-input__input--with-suffix { - border-top-right-radius: 0; - border-bottom-right-radius: 0; -} -.ds-input__prefix { - border-right: 0; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - white-space: nowrap; -} + &:has([data-size='sm']) { + @composes ds-body-text--sm from './utilities.css'; -.ds-input__suffix { - border-left: 0; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - white-space: nowrap; -} + --dsc-input-addons-padding: var(--ds-spacing-3); + } -.ds-input__readonly__icon { - height: 1.2em; - width: 1.2em; -} + &:has([data-size='lg']) { + @composes ds-body-text--lg from './utilities.css'; -.ds-input__error-message:empty { - display: none; + --dsc-input-addons-padding: var(--ds-spacing-5); + } } diff --git a/packages/react/src/components/form/Input/Input.mdx b/packages/react/src/components/form/Input/Input.mdx index 297c31891d..adfd75e0e5 100644 --- a/packages/react/src/components/form/Input/Input.mdx +++ b/packages/react/src/components/form/Input/Input.mdx @@ -19,10 +19,6 @@ Det er viktig at samme informasjon som vises i prefixet eller suffixet også er -## Antall tegn - - - ## Kontrollert diff --git a/packages/react/src/components/form/Input/Input.stories.tsx b/packages/react/src/components/form/Input/Input.stories.tsx index 406e96b2cd..06b283614d 100644 --- a/packages/react/src/components/form/Input/Input.stories.tsx +++ b/packages/react/src/components/form/Input/Input.stories.tsx @@ -1,9 +1,9 @@ import type { Meta, StoryFn, StoryObj } from '@storybook/react'; import { useState } from 'react'; -import { Button, Paragraph } from '../..'; +import { Button, Label, Paragraph } from '../..'; -import { Input } from '.'; +import { Input, InputAddon, InputAddons } from '.'; type Story = StoryObj; @@ -14,42 +14,45 @@ export default { export const Preview: Story = { args: { - label: 'Label', disabled: false, readOnly: false, size: 'md', - description: '', - error: '', - }, -}; - -export const WithCharacterCounter: Story = { - args: { - label: 'Label', - characterLimit: { - maxCount: 5, - }, }, + render: (args) => ( + <> + + + + ), }; - export const HtmlSize: Story = { args: { - label: 'Label', htmlSize: 10, }, + render: (args) => ( + <> + + + + ), }; -export const Adornments: Story = { - args: { - prefix: 'NOK', - suffix: 'pr. mnd', - size: 'md', - label: 'Hvor mange kroner koster det per måned?', - }, -}; +export const Adornments: StoryFn = (args) => ( + <> + + + + + + + +); -export const Controlled: StoryFn = () => { +export const Controlled: StoryFn = (args) => { const [value, setValue] = useState(); + return ( <> Du har skrevet inn: {value} @@ -61,11 +64,15 @@ export const Controlled: StoryFn = () => { gap: 'var(--ds-spacing-2)', }} > - setValue(e.target.value)} - /> +
+ + setValue(e.target.value)} + {...args} + /> +
diff --git a/packages/react/src/components/form/Input/Input.tsx b/packages/react/src/components/form/Input/Input.tsx index c532062c35..0c5ceb1dd2 100644 --- a/packages/react/src/components/form/Input/Input.tsx +++ b/packages/react/src/components/form/Input/Input.tsx @@ -1,130 +1,69 @@ -import { PadlockLockedFillIcon } from '@navikt/aksel-icons'; import cl from 'clsx/lite'; -import type { InputHTMLAttributes, ReactNode } from 'react'; -import { forwardRef, useId, useState } from 'react'; - -import { omit } from '../../../utilities'; -import type { CharacterLimitProps } from '../CharacterCounter'; -import type { FormFieldProps } from '../useFormField'; - -import { useInput } from './useInput'; +import type { HTMLAttributes, InputHTMLAttributes } from 'react'; +import { forwardRef } from 'react'; export type InputProps = { - /** Label */ - label?: ReactNode; - /** Visually hides `label` and `description` (still available for screen readers) */ - hideLabel?: boolean; /** * Changes field size and paddings * @default md */ size?: 'sm' | 'md' | 'lg'; - /** Prefix for field. */ - prefix?: string; - /** Suffix for field. */ - suffix?: string; /** Supported `input` types */ - type?: - | 'date' - | 'datetime-local' - | 'email' - | 'file' - | 'month' - | 'number' - | 'password' - | 'search' - | 'tel' - | 'text' - | 'time' - | 'url' - | 'week'; - /** - * The characterLimit function calculates remaining characters based on `maxCount` - * - * Provide a `label` function that takes count as parameter and returns a message. - * - * Use `srLabel` to describe `maxCount` for screen readers. - * - * Defaults to Norwegian if no labels are provided. - */ - characterLimit?: CharacterLimitProps; + type?: InputHTMLAttributes['type']; /** Exposes the HTML `size` attribute. * @default 20 */ htmlSize?: number; -} & Omit & - Omit, 'size'>; + /** Disables element + * @note Avoid using if possible for accessibility purposes + */ + disabled?: boolean; + /** Toggle `readOnly` */ + readOnly?: boolean; +} & Omit, 'size'>; -/** Text input field +/** Input field * * @example * ```tsx - * + * * ``` */ -export const Input = forwardRef( - function Input(props, ref) { - const { - label, - description, - suffix, - prefix, - style, - characterLimit, - hideLabel, - type = 'text', - htmlSize = 20, - className, - ...rest - } = props; - - const { - inputProps, - descriptionId, - hasError, - errorId, - size = 'md', - readOnly, - } = useInput(props); +export const Input = forwardRef(function Input( + { type = 'text', size = 'md', htmlSize = 20, className, ...rest }, + ref, +) { + return ( + + ); +}); - const [inputValue, setInputValue] = useState( - props.value || props.defaultValue, +export type InputAddonsProps = HTMLAttributes; +export const InputAddons = forwardRef( + function InputAdornments({ className, ...rest }, ref) { + return ( +
); - const characterLimitId = `Input-charactercount-${useId()}`; - const hasCharacterLimit = characterLimit != null; - - const describedBy = - cl( - inputProps['aria-describedby'], - hasCharacterLimit && characterLimitId, - ) || undefined; + }, +); +export type InputAddonProps = HTMLAttributes; +export const InputAddon = forwardRef( + function InputAddon({ className, ...rest }, ref) { return ( - { - inputProps?.onChange?.(e); - setInputValue(e.target.value); - }} + {...rest} /> ); }, ); - -export const InputAdornments = forwardRef( - function InputAdornments() { - return null; - }, -); diff --git a/packages/react/src/components/form/Input/useInput.ts b/packages/react/src/components/form/Input/useInput.ts deleted file mode 100644 index e7f5a42ba5..0000000000 --- a/packages/react/src/components/form/Input/useInput.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { InputHTMLAttributes } from 'react'; -import { useContext } from 'react'; - -import { FieldsetContext } from '../Fieldset/FieldsetContext'; -import type { FormField } from '../useFormField'; -import { useFormField } from '../useFormField'; - -import type { InputProps } from './Input'; - -type UseInput = (props: InputProps) => FormField & { - inputProps?: Pick< - InputHTMLAttributes, - 'readOnly' | 'type' | 'name' | 'required' | 'onClick' | 'onChange' - >; -}; -/** Handles props for `Input` in context with `Fieldset` */ -export const useInput: UseInput = (props) => { - const fieldset = useContext(FieldsetContext); - const { - inputProps, - readOnly, - size = fieldset?.size ?? 'md', - ...rest - } = useFormField(props, 'Input'); - - return { - ...rest, - readOnly, - size, - inputProps: { - ...inputProps, - readOnly, - onClick: (e) => { - if (readOnly) { - e.preventDefault(); - return; - } - props?.onClick?.(e); - }, - onChange: (e) => { - if (readOnly) { - e.preventDefault(); - return; - } - props?.onChange?.(e); - }, - }, - }; -}; From f13fe2f366e58534c1b9d8bfb6e1a88fdd1e8e1d Mon Sep 17 00:00:00 2001 From: eirikbacker Date: Tue, 1 Oct 2024 21:50:24 +0200 Subject: [PATCH 03/34] chore(Input): cleanup --- .../components/form/Input/Input.stories.tsx | 2 +- .../react/src/components/form/Input/Input.tsx | 20 +++---------------- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/packages/react/src/components/form/Input/Input.stories.tsx b/packages/react/src/components/form/Input/Input.stories.tsx index 06b283614d..ec9b87c71c 100644 --- a/packages/react/src/components/form/Input/Input.stories.tsx +++ b/packages/react/src/components/form/Input/Input.stories.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { Button, Label, Paragraph } from '../..'; -import { Input, InputAddon, InputAddons } from '.'; +import { Input, InputAddons } from '.'; type Story = StoryObj; diff --git a/packages/react/src/components/form/Input/Input.tsx b/packages/react/src/components/form/Input/Input.tsx index 0c5ceb1dd2..852cf545e5 100644 --- a/packages/react/src/components/form/Input/Input.tsx +++ b/packages/react/src/components/form/Input/Input.tsx @@ -1,5 +1,5 @@ import cl from 'clsx/lite'; -import type { HTMLAttributes, InputHTMLAttributes } from 'react'; +import type { HTMLAttributes, InputHTMLAttributes, ReactNode } from 'react'; import { forwardRef } from 'react'; export type InputProps = { @@ -45,25 +45,11 @@ export const Input = forwardRef(function Input( ); }); -export type InputAddonsProps = HTMLAttributes; +export type InputAddonsProps = Omit, 'prefix'>; export const InputAddons = forwardRef( - function InputAdornments({ className, ...rest }, ref) { + function InputAddons({ className, ...rest }, ref) { return (
); }, ); - -export type InputAddonProps = HTMLAttributes; -export const InputAddon = forwardRef( - function InputAddon({ className, ...rest }, ref) { - return ( -