slim-select 3.4.3 → 3.5.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 (73) hide show
  1. package/dist/classes.d.ts +46 -0
  2. package/dist/helpers.d.ts +5 -0
  3. package/dist/index.d.ts +53 -319
  4. package/dist/index.umd.d.ts +2 -0
  5. package/dist/react/index.d.ts +4 -319
  6. package/dist/react/index.js +1459 -1697
  7. package/dist/react/index.umd.js +2 -2
  8. package/dist/react/react.d.ts +17 -0
  9. package/dist/react/react.test.d.ts +1 -0
  10. package/dist/react/slim-select/react/index.d.ts +4 -0
  11. package/dist/render.d.ts +88 -0
  12. package/dist/select.d.ts +38 -0
  13. package/dist/settings.d.ts +39 -0
  14. package/dist/slimselect.cjs.js +2 -2
  15. package/dist/slimselect.cjs.js.map +1 -1
  16. package/dist/slimselect.css +1 -1
  17. package/dist/slimselect.css.map +1 -1
  18. package/dist/slimselect.es.js +1233 -1385
  19. package/dist/slimselect.es.js.map +1 -1
  20. package/dist/slimselect.iife.js +2 -2
  21. package/dist/slimselect.iife.js.map +1 -1
  22. package/dist/slimselect.js +2 -2
  23. package/dist/slimselect.js.map +1 -1
  24. package/dist/src/docs/main.d.ts +0 -0
  25. package/dist/src/docs/router.d.ts +2 -0
  26. package/dist/src/docs/store.d.ts +12 -0
  27. package/dist/store.d.ts +53 -0
  28. package/dist/vue/index.d.ts +4 -319
  29. package/dist/vue/index.js +1335 -1504
  30. package/dist/vue/index.umd.js +1 -1
  31. package/dist/vue/slim-select/vue/index.d.ts +4 -0
  32. package/package.json +30 -36
  33. package/src/slim-select/slimselect.scss +34 -5
  34. package/.nvmrc +0 -1
  35. package/.prettierignore +0 -1
  36. package/.prettierrc +0 -7
  37. package/CODE_OF_CONDUCT.md +0 -46
  38. package/CONTRIBUTING.md +0 -1
  39. package/cdn.html +0 -21
  40. package/docs.go +0 -12
  41. package/example.html +0 -25
  42. package/index.html +0 -76
  43. package/release.js +0 -191
  44. package/sitemap.js +0 -108
  45. package/src/slim-select/accessibility.test.ts +0 -782
  46. package/src/slim-select/classes.test.ts +0 -73
  47. package/src/slim-select/classes.ts +0 -117
  48. package/src/slim-select/helpers.test.ts +0 -162
  49. package/src/slim-select/helpers.ts +0 -61
  50. package/src/slim-select/index.test.ts +0 -1356
  51. package/src/slim-select/index.ts +0 -594
  52. package/src/slim-select/index.umd.ts +0 -8
  53. package/src/slim-select/react/index.tsx +0 -9
  54. package/src/slim-select/react/react.test.tsx +0 -498
  55. package/src/slim-select/react/react.tsx +0 -209
  56. package/src/slim-select/render.test.ts +0 -1927
  57. package/src/slim-select/render.ts +0 -1771
  58. package/src/slim-select/select.test.ts +0 -683
  59. package/src/slim-select/select.ts +0 -659
  60. package/src/slim-select/settings.test.ts +0 -285
  61. package/src/slim-select/settings.ts +0 -78
  62. package/src/slim-select/store.test.ts +0 -1007
  63. package/src/slim-select/store.ts +0 -433
  64. package/src/slim-select/vue/index.ts +0 -9
  65. package/src/slim-select/vue/vue.test.ts +0 -1091
  66. package/src/slim-select/vue/vue.vue +0 -224
  67. package/tsconfig.json +0 -26
  68. package/vite.config.docs.mts +0 -29
  69. package/vite.config.lib.es-cjs.ts +0 -40
  70. package/vite.config.lib.umd-iife.ts +0 -34
  71. package/vite.config.react.mts +0 -41
  72. package/vite.config.vue.mts +0 -40
  73. package/vitest.config.ts +0 -30
@@ -1,594 +0,0 @@
1
- import CssClasses from './classes'
2
- import { debounce, hasClassInTree, isEqual } from './helpers'
3
- import Render from './render'
4
- import Select from './select'
5
- import Settings from './settings'
6
- import Store, { Option, Optgroup } from './store'
7
-
8
- // Export classes
9
- export { Settings, Option, Optgroup }
10
-
11
- // Export interfaces from render
12
- export type { Main, Content, Search } from './render'
13
-
14
- export interface Config {
15
- select: string | Element
16
- data?: (Partial<Option> | Partial<Optgroup>)[]
17
- settings?: Partial<Settings>
18
- cssClasses?: Partial<CssClasses>
19
- events?: Events
20
- }
21
-
22
- export interface Events {
23
- search?: (
24
- searchValue: string,
25
- currentData: (Option | Optgroup)[]
26
- ) => Promise<(Partial<Option> | Partial<Optgroup>)[]> | (Partial<Option> | Partial<Optgroup>)[]
27
- searchFilter?: (option: Option, search: string) => boolean
28
- addable?: (
29
- value: string
30
- ) => Promise<Partial<Option> | string> | Partial<Option> | string | false | null | undefined | Error
31
- beforeChange?: (newVal: Option[], oldVal: Option[]) => boolean | void
32
- afterChange?: (newVal: Option[]) => void
33
- beforeOpen?: () => void
34
- afterOpen?: () => void
35
- beforeClose?: () => void
36
- afterClose?: () => void
37
- error?: (err: Error) => void
38
- }
39
-
40
- export default class SlimSelect {
41
- public selectEl: HTMLSelectElement
42
-
43
- // Classes
44
- public settings!: Settings
45
- public cssClasses!: CssClasses
46
- public select!: Select
47
- public store!: Store
48
- public render!: Render
49
-
50
- // Timeout tracking for cleanup
51
- private openTimeout: ReturnType<typeof setTimeout> | null = null
52
- private closeTimeout: ReturnType<typeof setTimeout> | null = null
53
-
54
- // Events
55
- public events = {
56
- search: undefined,
57
- searchFilter: (opt: Option, search: string) => {
58
- return opt.text.toLowerCase().indexOf(search.toLowerCase()) !== -1
59
- },
60
- addable: undefined,
61
- beforeChange: undefined,
62
- afterChange: undefined,
63
- beforeOpen: undefined,
64
- afterOpen: undefined,
65
- beforeClose: undefined,
66
- afterClose: undefined
67
- } as Events
68
-
69
- constructor(config: Config) {
70
- // Make sure you get the right element
71
- this.selectEl = (
72
- typeof config.select === 'string' ? document.querySelector(config.select) : config.select
73
- ) as HTMLSelectElement
74
- if (!this.selectEl) {
75
- if (config.events && config.events.error) {
76
- config.events.error(new Error('Could not find select element'))
77
- }
78
- return
79
- }
80
- if (this.selectEl.tagName !== 'SELECT') {
81
- if (config.events && config.events.error) {
82
- config.events.error(new Error('Element isnt of type select'))
83
- }
84
- return
85
- }
86
-
87
- // If select already has a slim select id on it lets destroy it first
88
- if (this.selectEl.dataset.ssid) {
89
- this.destroy()
90
- }
91
-
92
- // Set settings
93
- this.settings = new Settings(config.settings)
94
-
95
- // Set CSS classes
96
- this.cssClasses = new CssClasses(config.cssClasses)
97
-
98
- // Set events
99
- const debounceEvents = ['beforeOpen', 'afterOpen', 'beforeClose', 'afterClose']
100
- for (const key in config.events) {
101
- // Check if key exists in events
102
- if (!config.events.hasOwnProperty(key)) {
103
- continue
104
- }
105
-
106
- // Check if key is in debounceEvents
107
- if (debounceEvents.indexOf(key) !== -1) {
108
- ;(this.events as { [key: string]: any })[key] = debounce((config.events as { [key: string]: any })[key], 100)
109
- } else {
110
- ;(this.events as { [key: string]: any })[key] = (config.events as { [key: string]: any })[key]
111
- }
112
- }
113
-
114
- // Upate settings with type, style and classname
115
- this.settings.disabled = config.settings?.disabled ? config.settings.disabled : this.selectEl.disabled
116
- this.settings.isMultiple = this.selectEl.multiple
117
- this.settings.style = this.selectEl.style.cssText
118
- this.settings.class = this.selectEl.className.split(' ')
119
-
120
- // Set select class
121
- this.select = new Select(this.selectEl)
122
- // Ensure the select has an id for label associations
123
- if (!this.selectEl.id) {
124
- this.selectEl.id = this.settings.id
125
- }
126
- this.select.updateSelect(this.settings.id, this.settings.style, this.settings.class)
127
- this.select.hideUI() // Hide the original select element
128
-
129
- // Add select listeners
130
- this.select.onValueChange = (options: Option[]) => {
131
- // Run set selected from the values given
132
- this.setSelected(options.map((option) => option.id))
133
- }
134
- this.select.onClassChange = (classes: string[]) => {
135
- // Update settings with new class
136
- this.settings.class = classes
137
-
138
- // Run render updateClassStyles
139
- this.render.updateClassStyles()
140
- }
141
- this.select.onDisabledChange = (disabled: boolean) => {
142
- if (disabled) {
143
- this.disable()
144
- } else {
145
- this.enable()
146
- }
147
- }
148
- this.select.onOptionsChange = (data: (Option | Optgroup)[]) => {
149
- // Process the data (including empty data for clearing all options)
150
- // Empty data is safe to process here because if we were updating, the change would be queued
151
- // and onOptionsChange wouldn't be called directly
152
- this.setData(data || [])
153
- }
154
- // Set up label click handler to toggle SlimSelect
155
- // This allows clicking the label to open/close, matching the main div behavior
156
- this.select.onLabelClick = () => {
157
- if (!this.settings.disabled) {
158
- this.settings.isOpen ? this.close() : this.open()
159
- }
160
- }
161
-
162
- // Set store class
163
- const data = config.data ? config.data : this.select.getData()
164
- this.store = new Store(this.settings.isMultiple ? 'multiple' : 'single', data)
165
-
166
- // If data is passed update the original select element
167
- if (config.data) {
168
- this.select.updateOptions(this.store.getData())
169
- }
170
-
171
- // Set render renderCallbacks
172
- const renderCallbacks = {
173
- open: this.open.bind(this),
174
- close: this.close.bind(this),
175
- addable: this.events.addable ? this.events.addable : undefined,
176
- setSelected: this.setSelected.bind(this),
177
- addOption: this.addOption.bind(this),
178
- search: this.search.bind(this),
179
- beforeChange: this.events.beforeChange,
180
- afterChange: this.events.afterChange
181
- }
182
-
183
- // Setup render class
184
- this.render = new Render(this.settings, this.cssClasses, this.store, renderCallbacks)
185
- this.render.renderValues()
186
- this.render.renderOptions(this.store.getData())
187
-
188
- // Add aria-label or aria-labelledby if exists
189
- const selectAriaLabel = this.selectEl.getAttribute('aria-label')
190
- const selectAriaLabelledBy = this.selectEl.getAttribute('aria-labelledby')
191
-
192
- if (selectAriaLabel) {
193
- this.render.main.main.setAttribute('aria-label', selectAriaLabel)
194
- } else if (selectAriaLabelledBy) {
195
- this.render.main.main.setAttribute('aria-labelledby', selectAriaLabelledBy)
196
- }
197
-
198
- // Add render after original select element
199
- if (this.selectEl.parentNode) {
200
- this.selectEl.parentNode.insertBefore(this.render.main.main, this.selectEl.nextSibling)
201
- }
202
-
203
- // Add window resize listener to moveContent if window size changes
204
- window.addEventListener('resize', this.windowResize, false)
205
-
206
- // If the user wants to show the content forcibly on a specific side,
207
- // there is no need to listen for scroll events
208
- if (this.settings.openPosition === 'auto') {
209
- window.addEventListener('scroll', this.windowScroll, false)
210
- }
211
-
212
- // Add window visibility change listener to closeContent if window is hidden
213
- document.addEventListener('visibilitychange', this.windowVisibilityChange)
214
-
215
- // If disabled lets call it
216
- if (this.settings.disabled) {
217
- this.disable()
218
- }
219
-
220
- // If alwaysOpnen then open it
221
- if (this.settings.alwaysOpen) {
222
- this.open()
223
- }
224
-
225
- // Set up label handlers to open SlimSelect when label is clicked
226
- this.select.setupLabelHandlers()
227
-
228
- // Add SlimSelect to select element
229
- ;(this.selectEl as any).slim = this
230
- }
231
-
232
- // Set to enabled and remove disabled classes
233
- public enable(): void {
234
- this.settings.disabled = false
235
-
236
- this.select.enable()
237
- this.render.enable()
238
- }
239
-
240
- // Set to disabled and add disabled classes
241
- public disable(): void {
242
- this.settings.disabled = true
243
-
244
- this.select.disable()
245
- this.render.disable()
246
- }
247
-
248
- public getData(): Option[] | Optgroup[] {
249
- return this.store.getData()
250
- }
251
-
252
- public setData(data: (Partial<Option> | Partial<Optgroup>)[]): void {
253
- // Get original selected values
254
- const selected = this.store.getSelected()
255
-
256
- // Validate data
257
- const err = this.store.validateDataArray(data)
258
- if (err) {
259
- if (this.events.error) {
260
- this.events.error(err)
261
- }
262
- return
263
- }
264
-
265
- // Update the store
266
- this.store.setData(data)
267
- const dataClean = this.store.getData()
268
-
269
- // Update original select element
270
- this.select.updateOptions(dataClean)
271
-
272
- // Update the render
273
- this.render.renderValues()
274
- this.render.renderOptions(dataClean)
275
-
276
- // Trigger afterChange event, if it doesnt equal the original selected values
277
- if (this.events.afterChange && !isEqual(selected, this.store.getSelected())) {
278
- this.events.afterChange(this.store.getSelectedOptions())
279
- }
280
- }
281
-
282
- public getSelected(): string[] {
283
- let options = this.store.getSelectedOptions()
284
- if (this.settings.keepOrder) {
285
- options = this.store.selectedOrderOptions(options)
286
- }
287
- return options.map((option) => option.value)
288
- }
289
-
290
- // Will take in a string or array of strings and set the selected by either the id or value
291
- public setSelected(values: string | string[], runAfterChange = true): void {
292
- // Get original selected values
293
- const selected = this.store.getSelected()
294
- const options = this.store.getDataOptions()
295
- values = Array.isArray(values) ? values : [values]
296
- const ids = []
297
-
298
- // for back-compatibility support both, set by id and set by value
299
- for (const value of values) {
300
- if (options.find((option) => option.id == value)) {
301
- ids.push(value)
302
- continue
303
- }
304
-
305
- // if option with given id is not found try to search by value
306
- for (const option of options.filter((option) => option.value == value)) {
307
- ids.push(option.id)
308
- }
309
- }
310
-
311
- // Update the store
312
- this.store.setSelectedBy('id', ids)
313
- const data = this.store.getData()
314
-
315
- // Update the select element
316
- this.select.updateOptions(data)
317
-
318
- // Update the render
319
- this.render.renderValues()
320
-
321
- // If there is a search input value lets run through the search again
322
- // Otherwise we will just render the options from store data
323
- if (this.render.content.search.input.value !== '') {
324
- this.search(this.render.content.search.input.value)
325
- } else {
326
- this.render.renderOptions(data)
327
- }
328
-
329
- // Trigger afterChange event, if it doesnt equal the original selected values
330
- if (runAfterChange && this.events.afterChange && !isEqual(selected, this.store.getSelected())) {
331
- this.events.afterChange(this.store.getSelectedOptions())
332
- }
333
- }
334
-
335
- public addOption(option: Partial<Option>): void {
336
- // Get original selected values
337
- const selected = this.store.getSelected()
338
-
339
- // Add option to store if it does not already include the option
340
- if (!this.store.getDataOptions().some((o) => o.value === (option.value ?? option.text))) {
341
- this.store.addOption(option)
342
- }
343
- const data = this.store.getData()
344
-
345
- // Update the select element
346
- this.select.updateOptions(data)
347
-
348
- // Update the render
349
- this.render.renderValues()
350
- this.render.renderOptions(data)
351
-
352
- // Trigger afterChange event, if it doesnt equal the original selected values
353
- if (this.events.afterChange && !isEqual(selected, this.store.getSelected())) {
354
- this.events.afterChange(this.store.getSelectedOptions())
355
- }
356
- }
357
-
358
- public open(): void {
359
- // Dont open if disabled
360
- // Dont do anything if the content is already open
361
- if (this.settings.disabled || this.settings.isOpen) {
362
- return
363
- }
364
-
365
- // Clear any pending close timeout to prevent race conditions
366
- if (this.closeTimeout) {
367
- clearTimeout(this.closeTimeout)
368
- this.closeTimeout = null
369
- }
370
-
371
- // Run beforeOpen callback
372
- if (this.events.beforeOpen) {
373
- this.events.beforeOpen()
374
- }
375
-
376
- // Tell render to open
377
- this.render.open()
378
-
379
- // Focus on input field only if search is enabled
380
- if (this.settings.showSearch && this.settings.focusSearch) {
381
- this.render.searchFocus()
382
- }
383
-
384
- this.settings.isOpen = true
385
- // setTimeout is for animation completion
386
- this.openTimeout = setTimeout(() => {
387
- // Run afterOpen callback
388
- if (this.events.afterOpen) {
389
- this.events.afterOpen()
390
- }
391
-
392
- // Update settings
393
- // Prevent overide if user close fast without wait full open
394
- // For detail see issue https://github.com/brianvoe/slim-select/issues/397
395
- if (this.settings.isOpen) {
396
- this.settings.isFullOpen = true
397
- }
398
-
399
- // Add onclick listener to document to closeContent if clicked outside
400
- document.addEventListener('click', this.documentClick)
401
- }, this.settings.timeoutDelay)
402
-
403
- // Start an interval to check if main has moved
404
- // in order to keep content close to main
405
- if (this.settings.contentPosition === 'absolute') {
406
- if (this.settings.intervalMove) {
407
- clearInterval(this.settings.intervalMove)
408
- }
409
- this.settings.intervalMove = setInterval(this.render.moveContent.bind(this.render), 500)
410
- }
411
- }
412
-
413
- public close(eventType: string | null = null): void {
414
- // Dont do anything if the content is already closed
415
- // Dont do anything if alwaysOpen is true
416
- if (!this.settings.isOpen || this.settings.alwaysOpen) {
417
- return
418
- }
419
-
420
- // Clear any pending open timeout to prevent race conditions
421
- if (this.openTimeout) {
422
- clearTimeout(this.openTimeout)
423
- this.openTimeout = null
424
- }
425
-
426
- // Run beforeClose calback
427
- if (this.events.beforeClose) {
428
- this.events.beforeClose()
429
- }
430
-
431
- // Tell render to close
432
- this.render.close()
433
-
434
- // Clear search only if not empty and keepSearch is false
435
- if (!this.settings.keepSearch && this.render.content.search.input.value !== '') {
436
- this.search('') // Clear search
437
- }
438
-
439
- // If we arent tabbing focus back on the main element
440
- this.render.mainFocus(eventType)
441
-
442
- // Update settings
443
- this.settings.isOpen = false
444
- this.settings.isFullOpen = false
445
-
446
- // Reset the content below
447
- this.closeTimeout = setTimeout(() => {
448
- // Run afterClose callback
449
- if (this.events.afterClose) {
450
- this.events.afterClose()
451
- }
452
-
453
- // Add onclick listener to document to closeContent if clicked outside
454
- document.removeEventListener('click', this.documentClick)
455
- }, this.settings.timeoutDelay)
456
-
457
- if (this.settings.intervalMove) {
458
- clearInterval(this.settings.intervalMove)
459
- }
460
- }
461
-
462
- // Take in string value and search current options
463
- public search(value: string): void {
464
- // If the passed in value is not the same as the search input value
465
- // then lets update the search input value
466
- if (this.render.content.search.input.value !== value) {
467
- this.render.content.search.input.value = value
468
- }
469
-
470
- // If value is empty then render all options
471
- if (value === '') {
472
- this.render.renderOptions(this.store.getData())
473
- return
474
- }
475
-
476
- // If no search event run regular search
477
- if (!this.events.search) {
478
- // If value is empty then render all options
479
- const searchResults = value === '' ? this.store.getData() : this.store.search(value, this.events.searchFilter!)
480
- this.render.renderOptions(searchResults)
481
- return
482
- }
483
-
484
- // Search event exists so lets render the searching text
485
- this.render.renderSearching()
486
-
487
- // Based upon the search event deal with the response
488
- const searchResp = this.events.search(value, this.store.getSelectedOptions())
489
-
490
- // If the search event returns a promise
491
- if (searchResp instanceof Promise) {
492
- searchResp
493
- .then((data: (Partial<Option> | Partial<Optgroup>)[]) => {
494
- // Update store data with search results, preserving selected options
495
- this.store.setData(data, true)
496
-
497
- // Update original select element
498
- this.select.updateOptions(this.store.getData())
499
-
500
- // Render the updated data
501
- this.render.renderOptions(this.store.getData())
502
- })
503
- .catch((err: Error | string) => {
504
- // Update the render with error
505
- this.render.renderError(typeof err === 'string' ? err : err.message)
506
- })
507
-
508
- return
509
- } else if (Array.isArray(searchResp)) {
510
- // Update store data with search results, preserving selected options
511
- this.store.setData(searchResp, true)
512
-
513
- // Update original select element
514
- this.select.updateOptions(this.store.getData())
515
-
516
- // Render the updated data
517
- this.render.renderOptions(this.store.getData())
518
- } else {
519
- // Update the render with error
520
- this.render.renderError('Search event must return a promise or an array of data')
521
- }
522
- }
523
-
524
- public destroy(): void {
525
- // Clear any pending timeouts
526
- if (this.openTimeout) {
527
- clearTimeout(this.openTimeout)
528
- this.openTimeout = null
529
- }
530
- if (this.closeTimeout) {
531
- clearTimeout(this.closeTimeout)
532
- this.closeTimeout = null
533
- }
534
- if (this.settings.intervalMove) {
535
- clearInterval(this.settings.intervalMove)
536
- this.settings.intervalMove = null
537
- }
538
-
539
- // Remove all event listeners
540
- document.removeEventListener('click', this.documentClick)
541
- window.removeEventListener('resize', this.windowResize, false)
542
- if (this.settings.openPosition === 'auto') {
543
- window.removeEventListener('scroll', this.windowScroll, false)
544
- }
545
- document.removeEventListener('visibilitychange', this.windowVisibilityChange)
546
-
547
- // Delete the store data
548
- this.store.setData([])
549
-
550
- // Remove the render
551
- this.render.destroy()
552
-
553
- // Show the original select element
554
- this.select.destroy()
555
- }
556
-
557
- private windowResize: (e: Event) => void = debounce(() => {
558
- if (!this.settings.isOpen && !this.settings.isFullOpen) {
559
- return
560
- }
561
-
562
- this.render.moveContent()
563
- })
564
-
565
- // Event listener for window scrolling
566
- private windowScroll: (e: Event) => void = debounce(() => {
567
- // If the content is not open, there is no need to move it
568
- if (!this.settings.isOpen && !this.settings.isFullOpen) {
569
- return
570
- }
571
-
572
- this.render.moveContent()
573
- })
574
-
575
- // Event listener for document click
576
- private documentClick: (e: Event) => void = (e: Event) => {
577
- // If the content is not open, there is no need to close it
578
- if (!this.settings.isOpen) {
579
- return
580
- }
581
-
582
- // Check if the click was on the content by looking at the parents
583
- if (e.target && !hasClassInTree(e.target as HTMLElement, this.settings.id)) {
584
- this.close(e.type)
585
- }
586
- }
587
-
588
- // Event Listener for window visibility change
589
- private windowVisibilityChange: (e: Event) => void = () => {
590
- if (document.hidden) {
591
- this.close()
592
- }
593
- }
594
- }
@@ -1,8 +0,0 @@
1
- // UMD entry point - only exports the default SlimSelect class
2
- // This avoids the mixed exports issue for UMD/IIFE builds
3
- import SlimSelect, * as namedExports from './index'
4
-
5
- // Attach all named exports as properties on the default export
6
- Object.assign(SlimSelect, namedExports)
7
-
8
- export default SlimSelect
@@ -1,9 +0,0 @@
1
- import SlimSelect from './react'
2
-
3
- export default SlimSelect
4
-
5
- // Re-export classes
6
- export { Settings, Option, Optgroup } from '../index'
7
-
8
- // Re-export types
9
- export type { Config, Events } from '../index'