@oslokommune/punkt-react 17.1.2 → 18.0.0

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/components/backlink/BackLink.d.ts +3 -0
  3. package/dist/components/breadcrumbs/Breadcrumbs.d.ts +3 -0
  4. package/dist/components/calendar/types.d.ts +14 -12
  5. package/dist/components/checkbox/Checkbox.d.ts +5 -0
  6. package/dist/components/combobox/types.d.ts +5 -0
  7. package/dist/components/consent/strings.d.ts +1 -85
  8. package/dist/components/datepicker/DateTags.d.ts +2 -2
  9. package/dist/components/datepicker/types.d.ts +6 -3
  10. package/dist/components/fileupload/DropZone.d.ts +3 -0
  11. package/dist/components/fileupload/FileUpload.d.ts +3 -0
  12. package/dist/components/header/types.d.ts +5 -0
  13. package/dist/components/index.d.ts +4 -0
  14. package/dist/components/inputwrapper/InputWrapper.d.ts +6 -0
  15. package/dist/components/radio/RadioButton.d.ts +5 -0
  16. package/dist/components/searchinput/SearchInput.d.ts +4 -0
  17. package/dist/components/select/Select.d.ts +3 -0
  18. package/dist/components/strings/PktStringsProvider.d.ts +21 -0
  19. package/dist/components/strings/strings-override.test.d.ts +1 -0
  20. package/dist/components/tag/Tag.d.ts +3 -0
  21. package/dist/components/textarea/Textarea.d.ts +3 -0
  22. package/dist/components/textinput/Textinput.d.ts +3 -0
  23. package/dist/components/timepicker/types.d.ts +3 -2
  24. package/dist/components/timepicker/useTimepickerState.d.ts +9 -1
  25. package/dist/punkt-react.cjs +1 -1
  26. package/dist/punkt-react.js +2969 -2749
  27. package/dist/shared-strings/consent.d.ts +71 -0
  28. package/dist/shared-strings/format.d.ts +21 -0
  29. package/dist/shared-strings/index.d.ts +14 -0
  30. package/dist/shared-strings/nb.d.ts +118 -0
  31. package/dist/shared-strings/resolve.d.ts +12 -0
  32. package/dist/shared-strings/store.d.ts +27 -0
  33. package/dist/shared-strings/types.d.ts +29 -0
  34. package/dist/shared-types/fileupload.d.ts +0 -27
  35. package/dist/shared-types/index.d.ts +1 -2
  36. package/dist/shared-utils/combobox/index.d.ts +1 -0
  37. package/dist/shared-utils/combobox/input-utils.d.ts +2 -1
  38. package/dist/shared-utils/combobox/messages.d.ts +7 -0
  39. package/dist/shared-utils/combobox/option-utils.d.ts +2 -1
  40. package/dist/shared-utils/combobox/selection-manager.d.ts +3 -2
  41. package/package.json +4 -4
  42. package/src/components/backlink/BackLink.tsx +11 -2
  43. package/src/components/breadcrumbs/Breadcrumbs.tsx +8 -2
  44. package/src/components/calendar/Calendar.core.test.tsx +7 -5
  45. package/src/components/calendar/types.ts +14 -34
  46. package/src/components/calendar/useCalendarState.ts +16 -2
  47. package/src/components/checkbox/Checkbox.tsx +20 -4
  48. package/src/components/combobox/Combobox.tsx +1 -0
  49. package/src/components/combobox/Listbox.tsx +4 -2
  50. package/src/components/combobox/types.ts +6 -0
  51. package/src/components/combobox/useComboboxState.ts +15 -8
  52. package/src/components/consent/strings.ts +1 -125
  53. package/src/components/datepicker/DateTags.tsx +4 -3
  54. package/src/components/datepicker/Datepicker.accessibility.test.tsx +2 -2
  55. package/src/components/datepicker/Datepicker.tsx +1 -0
  56. package/src/components/datepicker/DatepickerInputs.tsx +6 -6
  57. package/src/components/datepicker/types.ts +6 -3
  58. package/src/components/datepicker/useDatepickerState.ts +4 -5
  59. package/src/components/fileupload/DropZone.tsx +20 -15
  60. package/src/components/fileupload/FileUpload.test.tsx +37 -0
  61. package/src/components/fileupload/FileUpload.tsx +41 -8
  62. package/src/components/header/HeaderGlobal.tsx +5 -2
  63. package/src/components/header/HeaderService.tsx +12 -7
  64. package/src/components/header/types.ts +6 -0
  65. package/src/components/index.ts +10 -0
  66. package/src/components/inputwrapper/InputWrapper.tsx +28 -6
  67. package/src/components/radio/RadioButton.tsx +18 -2
  68. package/src/components/searchinput/SearchInput.tsx +13 -3
  69. package/src/components/select/Select.tsx +5 -0
  70. package/src/components/strings/PktStringsProvider.tsx +38 -0
  71. package/src/components/strings/strings-override.test.tsx +130 -0
  72. package/src/components/tag/Tag.tsx +13 -2
  73. package/src/components/textarea/Textarea.tsx +5 -0
  74. package/src/components/textinput/Textinput.tsx +5 -0
  75. package/src/components/timepicker/Timepicker.tsx +1 -0
  76. package/src/components/timepicker/types.ts +3 -2
  77. package/src/components/timepicker/useTimepickerState.ts +20 -22
@@ -0,0 +1,130 @@
1
+ import { describe, expect, test, afterEach } from 'vitest'
2
+ import { render, screen, cleanup } from '@testing-library/react'
3
+ import { resetPktStrings, setPktStrings } from 'shared-strings'
4
+
5
+ import { PktBackLink } from '../backlink/BackLink'
6
+ import { PktBreadcrumbs } from '../breadcrumbs/Breadcrumbs'
7
+ import { PktFileUpload } from '../fileupload/FileUpload'
8
+ import { PktSearchInput } from '../searchinput/SearchInput'
9
+ import { PktTextinput } from '../textinput/Textinput'
10
+ import { PktStringsProvider } from './PktStringsProvider'
11
+
12
+ /**
13
+ * Sjekker at hvert av de tre lagene faktisk slår gjennom på ekte komponenter:
14
+ * global store, provider og `strings`-propen på komponenten.
15
+ */
16
+
17
+ afterEach(() => {
18
+ cleanup()
19
+ resetPktStrings()
20
+ })
21
+
22
+ describe('strings-propen per komponent', () => {
23
+ test('backlink bruker teksten fra propen', () => {
24
+ render(<PktBackLink href="/" strings={{ backlink: { label: 'Tilbake til lista' } }} />)
25
+ expect(screen.getByLabelText('Tilbake til lista')).toBeInTheDocument()
26
+ })
27
+
28
+ test('breadcrumbs bruker teksten fra propen', () => {
29
+ render(
30
+ <PktBreadcrumbs
31
+ breadcrumbs={[
32
+ { text: 'Hjem', href: '/' },
33
+ { text: 'Skjema', href: '/skjema' },
34
+ ]}
35
+ strings={{ breadcrumbs: { ariaLabel: 'Sti' } }}
36
+ />,
37
+ )
38
+ expect(screen.getByLabelText('Sti')).toBeInTheDocument()
39
+ })
40
+
41
+ test('searchinput bruker teksten fra propen', () => {
42
+ render(
43
+ <PktSearchInput id="s" strings={{ searchinput: { placeholder: 'Finn ansatt' } }} />,
44
+ )
45
+ expect(screen.getByPlaceholderText('Finn ansatt')).toBeInTheDocument()
46
+ })
47
+
48
+ test('fileupload bruker drop zone-tekst fra propen', () => {
49
+ render(
50
+ <PktFileUpload
51
+ name="files"
52
+ multiple
53
+ strings={{
54
+ fileupload: {
55
+ dropZoneDragMultiple: 'Dra dokumentene hit eller',
56
+ dropZoneOpenFileDialogMultiple: 'bla i filene',
57
+ },
58
+ }}
59
+ />,
60
+ )
61
+ expect(screen.getByText(/Dra dokumentene hit eller/)).toBeInTheDocument()
62
+ expect(screen.getByText('bla i filene')).toBeInTheDocument()
63
+ })
64
+
65
+ test('textinput sender propen videre til input-wrapperen', () => {
66
+ render(
67
+ <PktTextinput
68
+ id="t"
69
+ name="t"
70
+ label="Navn"
71
+ optionalTag
72
+ strings={{ forms: { optional: 'Frivillig' } }}
73
+ />,
74
+ )
75
+ expect(screen.getByText('Frivillig')).toBeInTheDocument()
76
+ })
77
+ })
78
+
79
+ describe('lagene i rekkefølge', () => {
80
+ test('propen vinner over den globale overstyringen', () => {
81
+ setPktStrings({ searchinput: { placeholder: 'Global' } })
82
+ render(<PktSearchInput id="s" strings={{ searchinput: { placeholder: 'Instans' } }} />)
83
+ expect(screen.getByPlaceholderText('Instans')).toBeInTheDocument()
84
+ })
85
+
86
+ test('den globale overstyringen gjelder uten prop', () => {
87
+ setPktStrings({ searchinput: { placeholder: 'Global' } })
88
+ render(<PktSearchInput id="s" />)
89
+ expect(screen.getByPlaceholderText('Global')).toBeInTheDocument()
90
+ })
91
+
92
+ test('provideren gjelder for undertreet', () => {
93
+ render(
94
+ <PktStringsProvider strings={{ forms: { optional: 'Kan stå tomt' } }}>
95
+ <PktTextinput id="t" name="t" label="Navn" optionalTag />
96
+ </PktStringsProvider>,
97
+ )
98
+ expect(screen.getByText('Kan stå tomt')).toBeInTheDocument()
99
+ })
100
+
101
+ test('propen vinner over provideren', () => {
102
+ render(
103
+ <PktStringsProvider strings={{ forms: { optional: 'Fra provider' } }}>
104
+ <PktTextinput
105
+ id="t"
106
+ name="t"
107
+ label="Navn"
108
+ optionalTag
109
+ strings={{ forms: { optional: 'Fra prop' } }}
110
+ />
111
+ </PktStringsProvider>,
112
+ )
113
+ expect(screen.getByText('Fra prop')).toBeInTheDocument()
114
+ })
115
+
116
+ test('den utfasede optionalText-propen vinner over alt', () => {
117
+ setPktStrings({ forms: { optional: 'Global' } })
118
+ render(
119
+ <PktTextinput
120
+ id="t"
121
+ name="t"
122
+ label="Navn"
123
+ optionalTag
124
+ optionalText="Gammel"
125
+ strings={{ forms: { optional: 'Fra prop' } }}
126
+ />,
127
+ )
128
+ expect(screen.getByText('Gammel')).toBeInTheDocument()
129
+ })
130
+ })
@@ -17,8 +17,13 @@ import {
17
17
  import type { TTagSkin } from 'shared-types'
18
18
 
19
19
  import { PktIcon } from '../icon/Icon'
20
+ import { usePktStrings } from '../../hooks/usePktStrings'
21
+ import { formatString, type TPktStringsOverride } from 'shared-strings'
20
22
 
21
23
  interface BasePktTagProps {
24
+ /** Overstyrer tekster for denne komponenten. */
25
+ strings?: TPktStringsOverride
26
+
22
27
  skin?: TTagSkin
23
28
  textStyle?: 'normal-text' | 'thin-text'
24
29
  size?: 'small' | 'medium' | 'large'
@@ -56,6 +61,7 @@ export const PktTag = forwardRef<HTMLSpanElement | HTMLButtonElement, ButtonPktT
56
61
  onClose,
57
62
  type,
58
63
  'aria-description': ariaDescriptionPropValue,
64
+ strings,
59
65
  ...props
60
66
  }: ButtonPktTagProps | SpanPktTagProps,
61
67
  forwardedRef: ForwardedRef<HTMLElement>,
@@ -76,9 +82,14 @@ export const PktTag = forwardRef<HTMLSpanElement | HTMLButtonElement, ButtonPktT
76
82
  setLabelText(spanRef.current?.textContent?.trim() ?? '')
77
83
  }, [children])
78
84
 
85
+ const s = usePktStrings(['tag'], strings)
86
+
79
87
  const ariaDescription = useMemo(() => {
80
- if (closeTag && !ariaLabel) return (labelText && `Klikk for å fjerne ${labelText}`) || ariaDescriptionPropValue
81
- }, [closeTag, ariaLabel, labelText, ariaDescriptionPropValue])
88
+ if (closeTag && !ariaLabel)
89
+ return (
90
+ (labelText && formatString(s.tag.remove, { label: labelText })) || ariaDescriptionPropValue
91
+ )
92
+ }, [closeTag, ariaLabel, labelText, ariaDescriptionPropValue, s])
82
93
 
83
94
  const classes = {
84
95
  'pkt-tag': true,
@@ -14,8 +14,11 @@ import {
14
14
  import { PktInputWrapper } from '../inputwrapper/InputWrapper'
15
15
  import { TPktInputSize } from 'shared-types/size'
16
16
  import { describedByIds } from 'shared-utils/forms/described-by'
17
+ import type { TPktStringsOverride } from 'shared-strings'
17
18
 
18
19
  export interface IPktTextarea extends TextareaHTMLAttributes<HTMLTextAreaElement> {
20
+ /** Overstyrer tekster for denne komponenten. */
21
+ strings?: TPktStringsOverride
19
22
  id: string
20
23
  ariaDescribedby?: string
21
24
  ariaLabelledby?: string
@@ -87,6 +90,7 @@ export const PktTextarea = forwardRef(
87
90
  readOnly,
88
91
  skipForwardTestid = false,
89
92
  inputSize = 'medium',
93
+ strings,
90
94
  ...props
91
95
  }: IPktTextarea,
92
96
  ref: ForwardedRef<HTMLTextAreaElement>,
@@ -125,6 +129,7 @@ export const PktTextarea = forwardRef(
125
129
  return (
126
130
  <div className={outerClasses}>
127
131
  <PktInputWrapper
132
+ strings={strings}
128
133
  ariaDescribedby={ariaDescribedby}
129
134
  disabled={disabled}
130
135
  errorMessage={errorMessage}
@@ -14,8 +14,11 @@ import {
14
14
  import { PktIcon } from '../icon/Icon'
15
15
  import { PktInputWrapper } from '../inputwrapper/InputWrapper'
16
16
  import { describedByIds } from 'shared-utils/forms/described-by'
17
+ import type { TPktStringsOverride } from 'shared-strings'
17
18
 
18
19
  export interface IPktTextinput extends InputHTMLAttributes<HTMLInputElement> {
20
+ /** Overstyrer tekster for denne komponenten. */
21
+ strings?: TPktStringsOverride
19
22
  id: string
20
23
  ariaDescribedby?: string
21
24
  ariaLabelledby?: string
@@ -106,6 +109,7 @@ export const PktTextinput = forwardRef(
106
109
  onChange,
107
110
  skipForwardTestid = false,
108
111
  inputSize = 'medium',
112
+ strings,
109
113
  ...props
110
114
  }: IPktTextinput,
111
115
  ref: ForwardedRef<HTMLInputElement>,
@@ -140,6 +144,7 @@ export const PktTextinput = forwardRef(
140
144
  return (
141
145
  <div className={outerClasses}>
142
146
  <PktInputWrapper
147
+ strings={strings}
143
148
  ariaDescribedby={ariaDescribedby}
144
149
  disabled={disabled}
145
150
  errorMessage={errorMessage}
@@ -220,6 +220,7 @@ export const PktTimepicker = forwardRef<HTMLDivElement, IPktTimepicker>((props,
220
220
  onBlur={state.handleFocusOut}
221
221
  >
222
222
  <PktInputWrapper
223
+ strings={state.stringsOverride}
223
224
  forId={state.hoursId}
224
225
  label={state.label}
225
226
  disabled={state.disabled}
@@ -1,6 +1,6 @@
1
+ import type { TPktStringsOverride } from 'shared-strings'
1
2
  import type { ChangeEvent, FocusEvent, InputHTMLAttributes, ReactNode } from 'react'
2
3
  import { TPktInputSize } from 'shared-types/size'
3
- import type { ITimepickerStrings } from 'shared-types/timepicker'
4
4
 
5
5
  /**
6
6
  * Props for {@link PktTimepicker}: a React time picker with `HH:MM`, aligned with Elements `pkt-timepicker`.
@@ -95,7 +95,8 @@ export interface IPktTimepicker extends Omit<
95
95
  ariaDescribedby?: string
96
96
 
97
97
  /** Copy for hours, minutes, buttons, and popup. */
98
- strings?: ITimepickerStrings
98
+ /** Overstyrer tekster for denne komponenten. */
99
+ strings?: TPktStringsOverride
99
100
 
100
101
  /** Change event from the hidden input (`e.target.value` is `HH:MM`). In React, `onChange` already fires on input. */
101
102
  onChange?: (e: ChangeEvent<HTMLInputElement>) => void
@@ -8,13 +8,14 @@ import {
8
8
  useRef,
9
9
  useState,
10
10
  } from 'react'
11
- import { defaultTimepickerStrings } from 'shared-types/timepicker'
12
11
  import { getHourOptions, getMinuteOptions, getMinuteStep } from 'shared-utils/timepicker/options'
13
12
  import { stepTime } from 'shared-utils/timepicker/stepper'
14
13
  import { isValidTimeString, timeToMinutes } from 'shared-utils/timepicker/time-utils'
15
14
 
16
15
  import type { IPktTimepicker } from './types'
17
16
  import { TPktInputSize } from 'shared-types/size'
17
+ import { usePktStrings } from '../../hooks/usePktStrings'
18
+ import { formatString, type TPktStrings } from 'shared-strings'
18
19
 
19
20
  /**
20
21
  * ## Why this hook exists (React form model)
@@ -42,15 +43,6 @@ import { TPktInputSize } from 'shared-types/size'
42
43
  * node” idea. React components don’t get `attachInternals()` for that API on a `<div>`, so this pattern
43
44
  * is the sustainable equivalent in plain React.
44
45
  */
45
- const FORM_MESSAGES = {
46
- required: 'Dette feltet er påkrevd',
47
- invalid: 'Ugyldig verdi',
48
- timeStepMismatch: 'Må treffe på et steg, for eksempel 0, {step} minutter',
49
- timeStepMismatchHour: 'Må treffe på hver hele time',
50
- timeStepMismatchHalfHour: 'Må treffe på hver hele eller halve time',
51
- rangeUnderflowMin: 'Verdien må være større enn eller lik {min}.',
52
- rangeOverflowMax: 'Verdien må være mindre enn eller lik {max}.',
53
- } as const
54
46
 
55
47
  function splitValid(value: string | undefined): [string, string] {
56
48
  if (value && isValidTimeString(value)) {
@@ -68,38 +60,41 @@ function displaySegmentsToCommittedTime(h: string, m: string): string {
68
60
  function validateTimeValue(
69
61
  value: string,
70
62
  opts: { required?: boolean; min?: string | number; max?: string | number; step?: number },
63
+ messages: TPktStrings['validation'],
71
64
  ): { valid: boolean; message?: string } {
72
65
  const { required, min, max, step } = opts
73
66
  if (required && !value) {
74
- return { valid: false, message: FORM_MESSAGES.required }
67
+ return { valid: false, message: messages.valueMissing }
75
68
  }
76
69
  if (!value) {
77
70
  return { valid: true }
78
71
  }
79
72
  if (!isValidTimeString(value)) {
80
- return { valid: false, message: FORM_MESSAGES.invalid }
73
+ return { valid: false, message: messages.typeMismatch }
81
74
  }
82
75
  const totalMinutes = timeToMinutes(value)
83
76
  const minuteStep = getMinuteStep(step)
84
77
  if (min && totalMinutes < timeToMinutes(String(min))) {
85
78
  return {
86
79
  valid: false,
87
- message: FORM_MESSAGES.rangeUnderflowMin.replace('{min}', String(min)),
80
+ message: formatString(messages.rangeUnderflowMin, { min: String(min) }),
88
81
  }
89
82
  }
90
83
  if (max && totalMinutes > timeToMinutes(String(max))) {
91
84
  return {
92
85
  valid: false,
93
- message: FORM_MESSAGES.rangeOverflowMax.replace('{max}', String(max)),
86
+ message: formatString(messages.rangeOverflowMax, { max: String(max) }),
94
87
  }
95
88
  }
96
89
  if (step && totalMinutes % minuteStep !== 0) {
97
90
  const stepMessage =
98
91
  minuteStep === 60
99
- ? FORM_MESSAGES.timeStepMismatchHour
92
+ ? messages.timeStepMismatchHour
100
93
  : minuteStep === 30
101
- ? FORM_MESSAGES.timeStepMismatchHalfHour
102
- : FORM_MESSAGES.timeStepMismatch.replace('{step}', `${minuteStep}, ${minuteStep * 2}, ${minuteStep * 3}`)
94
+ ? messages.timeStepMismatchHalfHour
95
+ : formatString(messages.timeStepMismatch, {
96
+ step: `${minuteStep}, ${minuteStep * 2}, ${minuteStep * 3}`,
97
+ })
103
98
  return { valid: false, message: stepMessage }
104
99
  }
105
100
  return { valid: true }
@@ -145,7 +140,9 @@ export function useTimepickerState(props: IPktTimepicker, ref: React.ForwardedRe
145
140
  onBlur: onBlurProp,
146
141
  } = props
147
142
 
148
- const strings = useMemo(() => stringsProp ?? defaultTimepickerStrings, [stringsProp])
143
+ const catalog = usePktStrings(['timepicker', 'validation'], stringsProp)
144
+ const strings = catalog.timepicker
145
+ const stringsOverride = stringsProp
149
146
 
150
147
  const isControlled = value !== undefined
151
148
  const initialStringValue = value !== undefined ? (value ?? '') : (defaultValue ?? '')
@@ -225,10 +222,10 @@ export function useTimepickerState(props: IPktTimepicker, ref: React.ForwardedRe
225
222
  useEffect(() => {
226
223
  const anchor = hoursInputRef.current
227
224
  if (!anchor) return
228
- const validation = validateTimeValue(effectiveValue, { required, min, max, step })
225
+ const validation = validateTimeValue(effectiveValue, { required, min, max, step }, catalog.validation)
229
226
  anchor.setCustomValidity(validation.valid ? '' : (validation.message ?? ''))
230
227
  setIsInvalid(touched && !validation.valid)
231
- }, [effectiveValue, required, min, max, step, touched])
228
+ }, [effectiveValue, required, min, max, step, touched, catalog.validation])
232
229
 
233
230
  const commitValue = useCallback(
234
231
  (newValue: string) => {
@@ -282,9 +279,9 @@ export function useTimepickerState(props: IPktTimepicker, ref: React.ForwardedRe
282
279
  const hidden = changeInputRef.current
283
280
  if (!hoursEl) return
284
281
  const raw = hidden?.value ?? ''
285
- const result = validateTimeValue(raw, { required, min, max, step })
282
+ const result = validateTimeValue(raw, { required, min, max, step }, catalog.validation)
286
283
  hoursEl.setCustomValidity(result.valid ? '' : (result.message ?? ''))
287
- }, [syncValueFromDisplay, required, min, max, step])
284
+ }, [syncValueFromDisplay, required, min, max, step, catalog.validation])
288
285
 
289
286
  useEffect(() => {
290
287
  const root = containerRef.current
@@ -793,6 +790,7 @@ export function useTimepickerState(props: IPktTimepicker, ref: React.ForwardedRe
793
790
  outerClasses,
794
791
  inputSize,
795
792
  strings,
793
+ stringsOverride,
796
794
  label,
797
795
  disabled,
798
796
  required,