-
Notifications
You must be signed in to change notification settings - Fork 230
/
Skeuocard.coffee
424 lines (367 loc) · 17.2 KB
/
Skeuocard.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
###
"Skeuocard" -- A Skeuomorphic Credit-Card Input Enhancement
@description Skeuocard is a skeuomorphic credit card input plugin, supporting
progressive enhancement. It renders a credit-card input which
behaves similarly to a physical credit card.
@author Ken Keiter <ken@kenkeiter.com>
@updated 2013-07-25
@website http://kenkeiter.com/
@exports [window.Skeuocard]
###
$ = jQuery
class Skeuocard
@currentDate: new Date()
constructor: (el, opts = {})->
@el = container: $(el), underlyingFields: {}
@_inputViews = {}
@_inputViewsByFace = {front: [], back: []}
@_tabViews = {}
@_state = {}
@product = null
@visibleFace = 'front'
# configure default opts
optDefaults =
debug: false
acceptedCardProducts: null
cardNumberPlaceholderChar: 'X'
genericPlaceholder: "XXXX XXXX XXXX XXXX"
typeInputSelector: '[name="cc_type"]'
numberInputSelector: '[name="cc_number"]'
expMonthInputSelector: '[name="cc_exp_month"]'
expYearInputSelector: '[name="cc_exp_year"]'
nameInputSelector: '[name="cc_name"]'
cvcInputSelector: '[name="cc_cvc"]'
initialValues: {}
validationState: {}
strings:
hiddenFaceFillPrompt: "<strong>Click here</strong> to <br>fill in the other side."
hiddenFaceErrorWarning: "There's a problem on the other side."
hiddenFaceSwitchPrompt: "Forget something?<br> Flip the card over."
@options = $.extend(optDefaults, opts)
# initialize the card
@_conformDOM() # conform the DOM, add our elements
@_bindInputEvents() # bind input and interaction events
@_importImplicitOptions() # import init options from DOM element attrs
@render() # perform initial render
# Debugging helper; if debug is set to true at instantiation, messages will
# be printed to the console.
_log: (msg...)->
if console?.log and !!@options.debug
console.log("[skeuocard]", msg...) if @options.debug?
# Trigger an event on a Skeuocard instance (jQuery's #trigger signature).
trigger: (args...)->
@el.container.trigger(args...)
# Bind an event handler on a Skeuocard instance (jQuery's #trigger signature).
bind: (args...)->
@el.container.bind(args...)
###
Transform the elements within the container, conforming the DOM so that it
becomes styleable, and that the underlying inputs are hidden.
###
_conformDOM: ->
@el.container.removeClass('no-js')
@el.container.addClass("skeuocard js")
# remove anything that's not an underlying form field
@el.container.find("> :not(input,select,textarea)").remove()
@el.container.find("> input,select,textarea").hide()
# Attach underlying form fields.
@el.underlyingFields =
type: @el.container.find(@options.typeInputSelector)
number: @el.container.find(@options.numberInputSelector)
expMonth: @el.container.find(@options.expMonthInputSelector)
expYear: @el.container.find(@options.expYearInputSelector)
name: @el.container.find(@options.nameInputSelector)
cvc: @el.container.find(@options.cvcInputSelector)
# construct the necessary card elements
@el.front = $("<div>").attr(class: "face front")
@el.back = $("<div>").attr(class: "face back")
@el.cardBody = $("<div>").attr(class: "card-body")
# add elements to the DOM
@el.front.appendTo(@el.cardBody)
@el.back.appendTo(@el.cardBody)
@el.cardBody.appendTo(@el.container)
# create the validation indicator (flip tab), and attach them.
@_tabViews.front = new Skeuocard::FlipTabView(@, 'front', strings: @options.strings)
@_tabViews.back = new Skeuocard::FlipTabView(@, 'back', strings: @options.strings)
@el.front.prepend(@_tabViews.front.el)
@el.back.prepend(@_tabViews.back.el)
@_tabViews.front.hide()
@_tabViews.back.hide()
# Create new input views, attach them to the appropriate surfaces
@_inputViews =
number: new @SegmentedCardNumberInputView()
exp: new @ExpirationInputView(currentDate: @options.currentDate)
name: new @TextInputView(class: "cc-name", placeholder: "YOUR NAME")
cvc: new @TextInputView(class: "cc-cvc", placeholder: "XXX", requireMaxLength: true)
# style and attach the number view to the DOM
@_inputViews.number.el.addClass('cc-number')
@_inputViews.number.el.appendTo(@el.front)
# attach name input
@_inputViews.name.el.appendTo(@el.front)
# style and attach the exp view to the DOM
@_inputViews.exp.el.addClass('cc-exp')
@_inputViews.exp.el.appendTo(@el.front)
# attach cvc field to the DOM
@_inputViews.cvc.el.appendTo(@el.back)
return @el.container
###
Import implicit initialization options from the DOM. Brings in things like
the accepted card type, initial validation state, existing values, etc.
###
_importImplicitOptions: ->
for fieldName, fieldEl of @el.underlyingFields
# import initial values, with constructor options taking precedence
unless @options.initialValues[fieldName]?
@options.initialValues[fieldName] = fieldEl.val()
else # update underlying field value so that it is canonical.
@options.initialValues[fieldName] = @options.initialValues[fieldName].toString()
@_setUnderlyingValue(fieldName, @options.initialValues[fieldName])
# set a flag if any fields were initially filled
if @options.initialValues[fieldName].length > 0
@_state['initiallyFilled'] = true
# import initial validation state
unless @options.validationState[fieldName]?
@options.validationState[fieldName] = not fieldEl.hasClass('invalid')
# If no explicit acceptedCardProducts were specified, determine accepted
# card products using the underlying type select field.
unless @options.acceptedCardProducts?
@options.acceptedCardProducts = []
@el.underlyingFields.type.find('option').each (i, _el)=>
el = $(_el)
shortname = el.attr('data-sc-type') || el.attr('value')
@options.acceptedCardProducts.push shortname
# setup default values; when render is called, these will be picked up
if @options.initialValues.number.length > 0
@set 'number', @options.initialValues.number
if @options.initialValues.name.length > 0
@set 'name', @options.initialValues.name
if @options.initialValues.cvc.length > 0
@set 'cvc', @options.initialValues.cvc
if @options.initialValues.expYear.length > 0 and
@options.initialValues.expMonth.length > 0
_initialExp = new Date parseInt(@options.initialValues.expYear),
parseInt(@options.initialValues.expMonth) - 1, 1
@set 'exp', _initialExp
@_updateValidationForFace('front')
@_updateValidationForFace('back')
set: (field, newValue)->
@_inputViews[field].setValue(newValue)
@_inputViews[field].trigger('valueChanged', @_inputViews[field])
###
Bind interaction events to their appropriate handlers.
###
_bindInputEvents: ->
# bind change handlers to render
@el.underlyingFields.number.bind "change", (e)=>
@_inputViews.number.setValue @_getUnderlyingValue('number')
@render()
_expirationChange = (e)=>
month = parseInt @_getUnderlyingValue('expMonth')
year = parseInt @_getUnderlyingValue('expYear')
@_inputViews.exp.setValue new Date(year, month - 1)
@render()
@el.underlyingFields.expMonth.bind "change", _expirationChange
@el.underlyingFields.expYear.bind "change", _expirationChange
@el.underlyingFields.name.bind "change", (e)=>
@_inputViews.exp.setValue @_getUnderlyingValue('name')
@render()
@el.underlyingFields.cvc.bind "change", (e)=>
@_inputViews.exp.setValue @_getUnderlyingValue('cvc')
@render()
# bind change events to their underlying form elements
@_inputViews.number.bind "change valueChanged", (e, input)=>
cardNumber = input.getValue()
@_setUnderlyingValue 'number', cardNumber
@_updateValidation 'number', cardNumber
# update the product if needed.
number = @_getUnderlyingValue('number')
matchedProduct = Skeuocard::CardProduct.firstMatchingNumber(number)
# check if the product is accepted
if not @product?.eql(matchedProduct)
@_log("Product will change:", @product, "=>", matchedProduct)
if matchedProduct?.attrs.companyShortname in @options.acceptedCardProducts
@trigger 'productWillChange.skeuocard', [@, @product, matchedProduct]
previousProduct = @product
@el.container.removeClass('unaccepted')
@_renderProduct(matchedProduct)
@product = matchedProduct
else if matchedProduct?
@trigger 'productWillChange.skeuocard', [@, @product, null]
@el.container.addClass('unaccepted')
@_renderProduct(null)
@product = null
else
@trigger 'productWillChange.skeuocard', [@, @product, null]
@el.container.removeClass('unaccepted')
@_renderProduct(null)
@product = null
@trigger 'productDidChange.skeuocard', [@, previousProduct, @product]
@_inputViews.exp.bind "keyup valueChanged", (e, input)=>
newDate = input.getValue()
@_updateValidation('exp', newDate)
if newDate?
@_setUnderlyingValue('expMonth', newDate.getMonth() + 1)
@_setUnderlyingValue('expYear', newDate.getFullYear())
@_inputViews.name.bind "keyup valueChanged", (e, input)=>
value = input.getValue()
@_setUnderlyingValue('name', value)
@_updateValidation('name', value)
@_inputViews.cvc.bind "keyup valueChanged", (e, input)=>
value = input.getValue()
@_setUnderlyingValue('cvc', value)
@_updateValidation('cvc', value)
@el.container.delegate "input", "keyup keydown", @_handleFieldTab.bind(@)
@_tabViews.front.el.click => @flip()
@_tabViews.back.el.click => @flip()
_handleFieldTab: (e)->
if e.which is 9 # tab
currentFieldEl = $(e.currentTarget)
_oppositeFace = if @visibleFace is 'front' then 'back' else 'front'
_currentFace = if @visibleFace is 'front' then 'front' else 'back'
backFieldEls = @el[_oppositeFace].find('input')
frontFieldEls = @el[_currentFace].find('input')
if @visibleFace is 'front' and
@el.front.hasClass('filled') and
backFieldEls.length > 0 and
frontFieldEls.index(currentFieldEl) is frontFieldEls.length-1 and
not e.shiftKey
@flip()
backFieldEls.first().focus()
e.preventDefault()
if @visibleFace is 'back' and e.shiftKey
@flip()
backFieldEls.last().focus() # other side, now...
e.preventDefault()
return true
_updateValidation: (fieldName, newValue)->
return false unless @product?
# Check against the current product to determine if the field is filled
isFilled = @product[fieldName].isFilled(newValue)
# If an initial value was supplied and marked as invalid, ensure that it
# has been changed to a new value.
needsFix = @options.validationState[fieldName]? is false
isFixed = @options.initialValues[fieldName]? and
newValue isnt @options.initialValues[fieldName]
# Check validity of value, asserting fixes have occurred if necessary.
isValid = @product[fieldName].isValid(newValue) and ((needsFix and isFixed) or true)
# Determine if state changed
fillStateChanged = @_state["#{fieldName}Filled"] isnt isFilled
validationStateChanged = @_state["#{fieldName}Valid"] isnt isValid
# If the fill state has changed, trigger events, and make styling changes.
if fillStateChanged
@trigger "fieldFillStateWillChange.skeuocard", [@, fieldName, isFilled]
@_inputViews[fieldName].el.toggleClass 'filled', isFilled
@_state["#{fieldName}Filled"] = isFilled
@trigger "fieldFillStateDidChange.skeuocard", [@, fieldName, isFilled]
# If the valid state has changed, trigger events, and make styling changes.
if validationStateChanged
@trigger "fieldValidationStateWillChange.skeuocard", [@, fieldName, isValid]
@_inputViews[fieldName].el.toggleClass 'valid', isValid
@_inputViews[fieldName].el.toggleClass 'invalid', not isValid
@_state["#{fieldName}Valid"] = isValid
@trigger "fieldValidationStateDidChange.skeuocard", [@, fieldName, isValid]
@_updateValidationForFace(@visibleFace)
_updateValidationForFace: (face)->
fieldsFilled = (iv.el.hasClass('filled') for iv in @_inputViewsByFace[face]).every(Boolean)
fieldsValid = (iv.el.hasClass('valid') for iv in @_inputViewsByFace[face]).every(Boolean)
isFilled = (fieldsFilled and @product?) or (@_state['initiallyFilled'] or false)
isValid = fieldsValid and @product?
fillStateChanged = @_state["#{face}Filled"] isnt isFilled
validationStateChanged = @_state["#{face}Valid"] isnt isValid
if fillStateChanged
@trigger "faceFillStateWillChange.skeuocard", [@, face, isFilled]
@el[face].toggleClass 'filled', isFilled
@_state["#{face}Filled"] = isFilled
@trigger "faceFillStateDidChange.skeuocard", [@, face, isFilled]
if validationStateChanged
@trigger "faceValidationStateWillChange.skeuocard", [@, face, isValid]
@el[face].toggleClass 'valid', isValid
@el[face].toggleClass 'invalid', not isValid
@_state["#{face}Valid"] = isValid
@trigger "faceValidationStateDidChange.skeuocard", [@, face, isValid]
###
Assert rendering changes necessary for the current product. Passing a null
value instead of a product will revert the card to a generic state.
###
_renderProduct: (product)->
@_log("[_renderProduct]", "Rendering product:", product)
# remove existing product and issuer classes (destyling product)
@el.container.removeClass (index, css)=>
(css.match(/\b(product|issuer)-\S+/g) || []).join(' ')
# add classes necessary to identify new product
if product?.attrs.companyShortname?
@el.container.addClass("product-#{product.attrs.companyShortname}")
if product?.attrs.issuerShortname?
@el.container.addClass("issuer-#{product.attrs.issuerShortname}")
# update the underlying card type field
@_setUnderlyingValue('type', product?.attrs.companyShortname || null)
# reconfigure the number input groupings
@_inputViews.number.setGroupings(product?.attrs.cardNumberGrouping ||
[@options.genericPlaceholder.length])
if product?
# reconfigure the expiration input groupings
@_inputViews.exp.reconfigure
pattern: product?.attrs.expirationFormat || "MM/YY"
# reconfigure the CVC
@_inputViews.cvc.attr
maxlength: product.attrs.cvcLength
placeholder: new Array(product.attrs.cvcLength + 1).join(@options.cardNumberPlaceholderChar)
# set visibility and re-layout fields
@_inputViewsByFace = {front: [], back: []}
focused = $('input:focus') # allow restoration of focus upon re-attachment
for fieldName, destFace of product.attrs.layout
@_log("Moving", fieldName, "to", destFace)
viewEl = @_inputViews[fieldName].el.detach()
viewEl.appendTo(@el[destFace])
@_inputViewsByFace[destFace].push @_inputViews[fieldName]
@_inputViews[fieldName].show()
# Restore focus. Use setTimeout to resolve IE10 issue.
setTimeout =>
if (fieldEl = focused.first())?
fieldLength = fieldEl[0].maxLength
fieldEl.focus()
fieldEl[0].setSelectionRange(fieldLength, fieldLength)
, 10
else
for fieldName, view of @_inputViews
view.hide() if fieldName isnt 'number'
return product
_renderValidation: ->
# update the validation state of all fields
for fieldName, fieldView of @_inputViews
@_updateValidation(fieldName, fieldView.getValue())
# Update the card's visual representation to reflect internal state.
render: ->
@_renderProduct(@product)
@_renderValidation()
# @_flipToInvalidSide()
# Flip the card over.
flip: ->
targetFace = if @visibleFace is 'front' then 'back' else 'front'
@trigger('faceWillBecomeVisible.skeuocard', [@, targetFace])
@visibleFace = targetFace
@el.cardBody.toggleClass('flip')
surfaceName = if @visibleFace is 'front' then 'front' else 'back'
@el[surfaceName].find('.cc-field').not('.filled').find('input').first().focus()
@trigger('faceDidBecomeVisible.skeuocard', [@, targetFace])
# Set a value in the underlying form.
_setUnderlyingValue: (field, newValue)->
fieldEl = @el.underlyingFields[field]
_newValue = (newValue || "").toString()
throw "Set underlying value of unknown field: #{field}." unless fieldEl?
@trigger('change.skeuocard', [@])
unless fieldEl.is('select')
@el.underlyingFields[field].val(_newValue)
else
remapAttrKey = "data-sc-" + field.toLowerCase()
fieldEl.find('option').each (i, _el)=>
optionEl = $(_el)
if _newValue is (optionEl.attr(remapAttrKey) || optionEl.attr('value'))
@el.underlyingFields[field].val optionEl.attr('value')
# Get a value from the underlying form.
_getUnderlyingValue: (field)->
@el.underlyingFields[field]?.val()
isValid: ->
not @el.front.hasClass('invalid') and not @el.back.hasClass('invalid')
# Export the object.
window.Skeuocard = Skeuocard